148
Cargo.lock
generated
148
Cargo.lock
generated
@@ -2,6 +2,15 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
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]]
|
[[package]]
|
||||||
name = "anstream"
|
name = "anstream"
|
||||||
version = "0.6.21"
|
version = "0.6.21"
|
||||||
@@ -58,6 +67,12 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
@@ -123,11 +138,14 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"env_logger",
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"memchr",
|
"memchr",
|
||||||
"nix",
|
"nix",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml",
|
||||||
|
"vt100",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -157,6 +175,29 @@ dependencies = [
|
|||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -202,6 +243,36 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.177"
|
||||||
@@ -218,6 +289,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -248,6 +325,21 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.105"
|
version = "1.0.105"
|
||||||
@@ -277,6 +369,35 @@ dependencies = [
|
|||||||
"thiserror",
|
"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]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@@ -400,12 +521,39 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
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]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -7,7 +7,7 @@
|
|||||||

|

|
||||||

|

|
||||||
|
|
||||||
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
|
## 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.
|
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
|
## The Solution
|
||||||
|
|
||||||
claude-chill sits between your terminal and Claude Code:
|
claude-chill sits between your terminal and Claude Code:
|
||||||
|
|
||||||
1. **Intercepts sync blocks** - Catches those massive atomic updates
|
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.
|
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. Clears on full screen clear, accumulates otherwise.
|
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
|
4. **Enables lookback** - Press a key to pause Claude and view the full history buffer
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -47,7 +35,6 @@ cargo install --path crates/claude-chill
|
|||||||
```bash
|
```bash
|
||||||
claude-chill claude
|
claude-chill claude
|
||||||
claude-chill -- claude --verbose # Use -- for command flags
|
claude-chill -- claude --verbose # Use -- for command flags
|
||||||
claude-chill -l 50 -- claude # Set max lines to 50
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lookback Mode
|
## 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`:
|
Create `~/.config/claude-chill.toml`:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
max_lines = 100 # Lines shown per sync block
|
history_lines = 100000 # Max lines stored for lookback
|
||||||
history_lines = 100000 # Lines stored for lookback
|
|
||||||
lookback_key = "[ctrl][6]"
|
lookback_key = "[ctrl][6]"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: History is cleared on full screen redraws, so lookback shows output since Claude's last full render.
|
||||||
|
|
||||||
### Key Format
|
### Key Format
|
||||||
|
|
||||||
`[modifier][key]` - Examples: `[f12]`, `[ctrl][g]`, `[ctrl][shift][j]`
|
`[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
|
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
|
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
|
3. **VT emulation**: Feeds output through a VT100 emulator to track the virtual screen state
|
||||||
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
|
4. **Differential rendering**: Compares current screen to previous and emits only the changes
|
||||||
5. **History tracking**: Maintains a rolling buffer of output for lookback mode
|
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
|
6. **Signal forwarding**: Window resize (SIGWINCH), interrupt (SIGINT), and terminate (SIGTERM) signals are forwarded to Claude
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version.workspace = true
|
|||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
default-run = "claude-chill"
|
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"
|
license = "MIT"
|
||||||
repository = "https://github.com/davidbeesley/claude-chill"
|
repository = "https://github.com/davidbeesley/claude-chill"
|
||||||
|
|
||||||
@@ -25,3 +25,6 @@ nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"]
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
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;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
fn main() -> 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 cli = cli::Cli::parse();
|
||||||
let config = Config::load();
|
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 history_lines = cli.history_lines.unwrap_or(config.history_lines);
|
||||||
|
|
||||||
let lookback_key = cli
|
let lookback_key = cli
|
||||||
@@ -22,13 +37,12 @@ fn main() -> ExitCode {
|
|||||||
Ok(key) => key.to_escape_sequence(),
|
Ok(key) => key.to_escape_sequence(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Invalid lookback key '{}': {}", lookback_key, e);
|
eprintln!("Invalid lookback key '{}': {}", lookback_key, e);
|
||||||
eprintln!("Using default: [ctrl][shift][j]");
|
eprintln!("Using default: [ctrl][6]");
|
||||||
config.lookback_sequence()
|
config.lookback_sequence()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let proxy_config = ProxyConfig {
|
let proxy_config = ProxyConfig {
|
||||||
max_output_lines: max_lines,
|
|
||||||
max_history_lines: history_lines,
|
max_history_lines: history_lines,
|
||||||
lookback_key,
|
lookback_key,
|
||||||
lookback_sequence,
|
lookback_sequence,
|
||||||
|
|||||||
@@ -1,59 +1,16 @@
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(name = "claude-chill", version)]
|
||||||
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 {
|
pub struct Cli {
|
||||||
#[arg(help = "Command to run", required = true, value_name = "COMMAND")]
|
|
||||||
pub command: String,
|
pub command: String,
|
||||||
|
|
||||||
#[arg(
|
#[arg(trailing_var_arg = true)]
|
||||||
help = "Arguments passed to command (use -- before command flags)",
|
|
||||||
value_name = "ARGS",
|
|
||||||
trailing_var_arg = true
|
|
||||||
)]
|
|
||||||
pub args: Vec<String>,
|
pub args: Vec<String>,
|
||||||
|
|
||||||
#[arg(
|
#[arg(short = 'H', long = "history")]
|
||||||
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>,
|
pub history_lines: Option<usize>,
|
||||||
|
|
||||||
#[arg(
|
#[arg(short = 'k', long = "lookback-key")]
|
||||||
short = 'k',
|
|
||||||
long = "lookback-key",
|
|
||||||
help = "Key to trigger lookback (e.g., [ctrl][shift][j])",
|
|
||||||
value_name = "KEY"
|
|
||||||
)]
|
|
||||||
pub lookback_key: Option<String>,
|
pub lookback_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][6]";
|
const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][6]";
|
||||||
|
const DEFAULT_REFRESH_RATE: u64 = 20;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub max_lines: usize,
|
|
||||||
pub history_lines: usize,
|
pub history_lines: usize,
|
||||||
pub lookback_key: String,
|
pub lookback_key: String,
|
||||||
|
pub refresh_rate: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_lines: 100,
|
|
||||||
history_lines: 100_000,
|
history_lines: 100_000,
|
||||||
lookback_key: DEFAULT_LOOKBACK_KEY.to_string(),
|
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())
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -87,9 +93,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
assert_eq!(config.max_lines, 100);
|
|
||||||
assert_eq!(config.history_lines, 100_000);
|
assert_eq!(config.history_lines, 100_000);
|
||||||
assert_eq!(config.lookback_key, "[ctrl][6]");
|
assert_eq!(config.lookback_key, "[ctrl][6]");
|
||||||
|
assert_eq!(config.refresh_rate, 20);
|
||||||
|
assert_eq!(config.redraw_throttle_ms(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ pub mod escape_sequences;
|
|||||||
pub mod key_parser;
|
pub mod key_parser;
|
||||||
pub mod line_buffer;
|
pub mod line_buffer;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
pub mod redraw_throttler;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::escape_sequences::{
|
use crate::escape_sequences::{
|
||||||
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY,
|
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,
|
CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
|
||||||
SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START,
|
SYNC_END, SYNC_START,
|
||||||
};
|
};
|
||||||
use crate::line_buffer::LineBuffer;
|
use crate::line_buffer::LineBuffer;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use log::debug;
|
||||||
use memchr::memmem;
|
use memchr::memmem;
|
||||||
use nix::errno::Errno;
|
use nix::errno::Errno;
|
||||||
use nix::fcntl::{FcntlArg, OFlag, fcntl};
|
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::os::unix::process::CommandExt;
|
||||||
use std::process::{Child, Command, ExitStatus};
|
use std::process::{Child, Command, ExitStatus};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
|
static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
|
||||||
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
|
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
|
||||||
static SIGTERM_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) {
|
extern "C" fn handle_sigwinch(_: libc::c_int) {
|
||||||
SIGWINCH_RECEIVED.store(true, Ordering::SeqCst);
|
SIGWINCH_RECEIVED.store(true, Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
@@ -36,7 +45,6 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ProxyConfig {
|
pub struct ProxyConfig {
|
||||||
pub max_output_lines: usize,
|
|
||||||
pub max_history_lines: usize,
|
pub max_history_lines: usize,
|
||||||
pub lookback_key: String,
|
pub lookback_key: String,
|
||||||
pub lookback_sequence: Vec<u8>,
|
pub lookback_sequence: Vec<u8>,
|
||||||
@@ -45,7 +53,6 @@ pub struct ProxyConfig {
|
|||||||
impl Default for ProxyConfig {
|
impl Default for ProxyConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_output_lines: 100,
|
|
||||||
max_history_lines: 100_000,
|
max_history_lines: 100_000,
|
||||||
lookback_key: "[ctrl][6]".to_string(),
|
lookback_key: "[ctrl][6]".to_string(),
|
||||||
lookback_sequence: vec![0x1E],
|
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 {
|
pub struct Proxy {
|
||||||
config: ProxyConfig,
|
config: ProxyConfig,
|
||||||
pty_master: OwnedFd,
|
pty_master: OwnedFd,
|
||||||
child: Child,
|
child: Child,
|
||||||
original_termios: Option<Termios>,
|
original_termios: Option<Termios>,
|
||||||
history: LineBuffer,
|
history: LineBuffer,
|
||||||
|
vt_parser: vt100::Parser,
|
||||||
|
vt_prev_screen: Option<vt100::Screen>,
|
||||||
|
last_output_time: Option<Instant>,
|
||||||
sync_buffer: Vec<u8>,
|
sync_buffer: Vec<u8>,
|
||||||
in_sync_block: bool,
|
in_sync_block: bool,
|
||||||
in_lookback_mode: bool,
|
in_lookback_mode: bool,
|
||||||
in_alternate_screen: bool,
|
in_alternate_screen: bool,
|
||||||
|
vt_render_pending: bool,
|
||||||
lookback_cache: Vec<u8>,
|
lookback_cache: Vec<u8>,
|
||||||
input_buffer: Vec<u8>,
|
lookback_input_buffer: Vec<u8>,
|
||||||
output_buffer: Vec<u8>,
|
output_buffer: Vec<u8>,
|
||||||
sync_start_finder: memmem::Finder<'static>,
|
sync_start_finder: memmem::Finder<'static>,
|
||||||
sync_end_finder: memmem::Finder<'static>,
|
sync_end_finder: memmem::Finder<'static>,
|
||||||
@@ -140,18 +154,31 @@ impl Proxy {
|
|||||||
drop(pty.slave);
|
drop(pty.slave);
|
||||||
set_nonblocking(&pty.master)?;
|
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 {
|
Ok(Self {
|
||||||
history: LineBuffer::new(config.max_history_lines),
|
history,
|
||||||
config,
|
config,
|
||||||
pty_master: pty.master,
|
pty_master: pty.master,
|
||||||
child,
|
child,
|
||||||
original_termios: terminal_guard.take(),
|
original_termios: terminal_guard.take(),
|
||||||
|
vt_parser,
|
||||||
|
vt_prev_screen: None,
|
||||||
|
last_output_time: None,
|
||||||
sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY),
|
sync_buffer: Vec::with_capacity(SYNC_BUFFER_CAPACITY),
|
||||||
in_sync_block: false,
|
in_sync_block: false,
|
||||||
in_lookback_mode: false,
|
in_lookback_mode: false,
|
||||||
in_alternate_screen: false,
|
in_alternate_screen: false,
|
||||||
|
vt_render_pending: false,
|
||||||
lookback_cache: Vec::new(),
|
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),
|
output_buffer: Vec::with_capacity(OUTPUT_BUFFER_CAPACITY),
|
||||||
sync_start_finder: memmem::Finder::new(SYNC_START),
|
sync_start_finder: memmem::Finder::new(SYNC_START),
|
||||||
sync_end_finder: memmem::Finder::new(SYNC_END),
|
sync_end_finder: memmem::Finder::new(SYNC_END),
|
||||||
@@ -189,13 +216,23 @@ impl Proxy {
|
|||||||
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
|
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
|
||||||
];
|
];
|
||||||
|
|
||||||
match poll(&mut poll_fds, PollTimeout::from(100u16)) {
|
let poll_timeout_ms = self
|
||||||
Ok(0) => continue,
|
.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(_) => {}
|
Ok(_) => {}
|
||||||
Err(Errno::EINTR) => continue,
|
Err(Errno::EINTR) => continue,
|
||||||
Err(e) => anyhow::bail!("poll failed: {}", e),
|
Err(e) => anyhow::bail!("poll failed: {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.flush_pending_vt_render(&stdout_fd)?;
|
||||||
|
|
||||||
if let Some(revents) = poll_fds[0].revents() {
|
if let Some(revents) = poll_fds[0].revents() {
|
||||||
if revents.contains(PollFlags::POLLIN) {
|
if revents.contains(PollFlags::POLLIN) {
|
||||||
match nix_read(&self.pty_master, &mut buf) {
|
match nix_read(&self.pty_master, &mut buf) {
|
||||||
@@ -223,47 +260,86 @@ impl Proxy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !self.sync_buffer.is_empty() {
|
// Final render before exit
|
||||||
write_all(&stdout_fd, &self.sync_buffer)?;
|
if self.vt_render_pending {
|
||||||
|
self.render_vt_screen(&stdout_fd)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.wait_child()
|
self.wait_child()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process_output<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
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 {
|
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);
|
return self.process_output_alt_screen(data, stdout_fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.in_lookback_mode {
|
if self.in_lookback_mode {
|
||||||
|
debug!("process_output: caching {} bytes for lookback", data.len());
|
||||||
self.lookback_cache.extend_from_slice(data);
|
self.lookback_cache.extend_from_slice(data);
|
||||||
return Ok(());
|
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() {
|
while pos < data.len() {
|
||||||
|
// Check for alt screen enter
|
||||||
if let Some(alt_pos) = self.find_alt_screen_enter(&data[pos..]) {
|
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 {
|
if self.in_sync_block {
|
||||||
self.sync_buffer
|
self.sync_buffer.extend_from_slice(remaining);
|
||||||
.extend_from_slice(&data[pos..pos + alt_pos]);
|
self.flush_sync_block_to_history();
|
||||||
write_all(stdout_fd, &self.sync_buffer)?;
|
|
||||||
self.sync_buffer.clear();
|
|
||||||
self.in_sync_block = false;
|
self.in_sync_block = false;
|
||||||
} else if alt_pos > 0 {
|
} else {
|
||||||
write_all(stdout_fd, &data[pos..pos + alt_pos])?;
|
self.history.push_bytes(remaining);
|
||||||
}
|
}
|
||||||
self.in_alternate_screen = true;
|
self.in_alternate_screen = true;
|
||||||
let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]);
|
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])?;
|
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);
|
return self.process_output_alt_screen(&data[pos + alt_pos + seq_len..], stdout_fd);
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.in_sync_block {
|
if self.in_sync_block {
|
||||||
if let Some(idx) = self.sync_end_finder.find(&data[pos..]) {
|
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(&data[pos..pos + idx]);
|
||||||
self.sync_buffer.extend_from_slice(SYNC_END);
|
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;
|
self.in_sync_block = false;
|
||||||
pos += idx + SYNC_END.len();
|
pos += idx + SYNC_END.len();
|
||||||
} else {
|
} else {
|
||||||
@@ -271,15 +347,18 @@ impl Proxy {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else if let Some(idx) = self.sync_start_finder.find(&data[pos..]) {
|
} 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 {
|
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.in_sync_block = true;
|
||||||
self.sync_buffer.clear();
|
self.sync_buffer.clear();
|
||||||
self.sync_buffer.extend_from_slice(SYNC_START);
|
self.sync_buffer.extend_from_slice(SYNC_START);
|
||||||
pos += idx + SYNC_START.len();
|
pos += idx + SYNC_START.len();
|
||||||
} else {
|
} else {
|
||||||
write_all(stdout_fd, &data[pos..])?;
|
// No sync block, just add to history
|
||||||
|
self.history.push_bytes(&data[pos..]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,15 +368,50 @@ impl Proxy {
|
|||||||
|
|
||||||
fn process_output_alt_screen<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
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) {
|
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])?;
|
write_all(stdout_fd, &data[..exit_pos])?;
|
||||||
let seq_len = self.alt_screen_exit_len(&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])?;
|
write_all(stdout_fd, &data[exit_pos..exit_pos + seq_len])?;
|
||||||
self.in_alternate_screen = false;
|
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)
|
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> {
|
fn find_alt_screen_enter(&self, data: &[u8]) -> Option<usize> {
|
||||||
let pos1 = self.alt_screen_enter_finder.find(data);
|
let pos1 = self.alt_screen_enter_finder.find(data);
|
||||||
let pos2 = self.alt_screen_enter_legacy_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_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 has_cursor_home = self.cursor_home_finder.find(&self.sync_buffer).is_some();
|
||||||
let is_full_redraw = has_clear_screen && has_cursor_home;
|
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 {
|
if is_full_redraw {
|
||||||
|
debug!("CLEARING HISTORY");
|
||||||
self.history.clear();
|
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.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)?;
|
fn flush_pending_vt_render<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||||
} else {
|
if !self.vt_render_pending || self.in_lookback_mode || self.in_alternate_screen {
|
||||||
write_all(stdout_fd, &self.sync_buffer)?;
|
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(())
|
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.clear();
|
||||||
self.output_buffer.extend_from_slice(SYNC_START);
|
self.output_buffer.extend_from_slice(SYNC_START);
|
||||||
self.output_buffer.extend_from_slice(CLEAR_SCREEN);
|
|
||||||
self.output_buffer.extend_from_slice(CLEAR_SCROLLBACK);
|
match &self.vt_prev_screen {
|
||||||
self.output_buffer.extend_from_slice(CURSOR_HOME);
|
Some(prev) => {
|
||||||
self.history
|
// Diff-based render: only send changes
|
||||||
.append_last_n_lines(self.config.max_output_lines, &mut self.output_buffer);
|
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);
|
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<()> {
|
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
|
||||||
@@ -375,48 +561,89 @@ impl Proxy {
|
|||||||
|
|
||||||
for &byte in data {
|
for &byte in data {
|
||||||
if self.in_lookback_mode && byte == 0x03 {
|
if self.in_lookback_mode && byte == 0x03 {
|
||||||
self.input_buffer.clear();
|
self.lookback_input_buffer.clear();
|
||||||
self.exit_lookback_mode(stdout_fd)?;
|
self.exit_lookback_mode(stdout_fd)?;
|
||||||
continue;
|
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() {
|
self.lookback_input_buffer.push(byte);
|
||||||
let excess = self.input_buffer.len() - self.config.lookback_sequence.len();
|
|
||||||
if !self.in_lookback_mode {
|
if self.lookback_input_buffer.len() > self.config.lookback_sequence.len() {
|
||||||
write_all(&self.pty_master, &self.input_buffer[..excess])?;
|
let excess = self.lookback_input_buffer.len() - self.config.lookback_sequence.len();
|
||||||
}
|
self.lookback_input_buffer.drain(..excess);
|
||||||
self.input_buffer.drain(..excess);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.input_buffer == self.config.lookback_sequence {
|
match lookback_action {
|
||||||
self.input_buffer.clear();
|
SequenceMatch::Complete => {
|
||||||
|
self.lookback_input_buffer.clear();
|
||||||
if self.in_lookback_mode {
|
if self.in_lookback_mode {
|
||||||
self.exit_lookback_mode(stdout_fd)?;
|
self.exit_lookback_mode(stdout_fd)?;
|
||||||
} else {
|
} else {
|
||||||
self.enter_lookback_mode()?;
|
self.enter_lookback_mode()?;
|
||||||
}
|
}
|
||||||
} else if !self
|
continue;
|
||||||
|
}
|
||||||
|
SequenceMatch::Partial => {}
|
||||||
|
SequenceMatch::None => {
|
||||||
|
if !self
|
||||||
.config
|
.config
|
||||||
.lookback_sequence
|
.lookback_sequence
|
||||||
.starts_with(&self.input_buffer)
|
.starts_with(&self.lookback_input_buffer)
|
||||||
{
|
{
|
||||||
if !self.in_lookback_mode {
|
self.lookback_input_buffer.clear();
|
||||||
write_all(&self.pty_master, &self.input_buffer)?;
|
|
||||||
}
|
}
|
||||||
self.input_buffer.clear();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lookback_action == SequenceMatch::None && !self.in_lookback_mode {
|
||||||
|
write_all(&self.pty_master, &[byte])?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
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<()> {
|
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.in_lookback_mode = true;
|
||||||
self.lookback_cache.clear();
|
self.lookback_cache.clear();
|
||||||
|
self.vt_render_pending = false;
|
||||||
|
|
||||||
self.output_buffer.clear();
|
self.output_buffer.clear();
|
||||||
self.history.append_all(&mut self.output_buffer);
|
self.history.append_all(&mut self.output_buffer);
|
||||||
|
debug!(
|
||||||
|
"enter_lookback_mode: output_buffer_len={}",
|
||||||
|
self.output_buffer.len()
|
||||||
|
);
|
||||||
|
|
||||||
let stdout_fd = io::stdout();
|
let stdout_fd = io::stdout();
|
||||||
write_all(&stdout_fd, CLEAR_SCREEN)?;
|
write_all(&stdout_fd, CLEAR_SCREEN)?;
|
||||||
@@ -433,25 +660,49 @@ impl Proxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn exit_lookback_mode<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
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;
|
self.in_lookback_mode = false;
|
||||||
|
|
||||||
|
// Process cached output through VT to update screen state
|
||||||
let cached = std::mem::take(&mut self.lookback_cache);
|
let cached = std::mem::take(&mut self.lookback_cache);
|
||||||
if !cached.is_empty() {
|
if !cached.is_empty() {
|
||||||
|
debug!(
|
||||||
|
"exit_lookback_mode: processing {} cached bytes",
|
||||||
|
cached.len()
|
||||||
|
);
|
||||||
self.process_output(&cached, stdout_fd)?;
|
self.process_output(&cached, stdout_fd)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.output_buffer.clear();
|
// Reset sync block state
|
||||||
self.history.append_all(&mut self.output_buffer);
|
self.in_sync_block = false;
|
||||||
|
self.sync_buffer.clear();
|
||||||
|
|
||||||
write_all(stdout_fd, CLEAR_SCREEN)?;
|
self.forward_winsize()?;
|
||||||
write_all(stdout_fd, CURSOR_HOME)?;
|
|
||||||
write_all(stdout_fd, &self.output_buffer)?;
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn forward_winsize(&self) -> Result<()> {
|
fn forward_winsize(&mut self) -> Result<()> {
|
||||||
if let Ok(winsize) = get_terminal_size() {
|
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 {
|
unsafe {
|
||||||
libc::ioctl(
|
libc::ioctl(
|
||||||
self.pty_master.as_raw_fd(),
|
self.pty_master.as_raw_fd(),
|
||||||
@@ -493,7 +744,7 @@ fn get_terminal_size() -> Result<Winsize> {
|
|||||||
&mut ws,
|
&mut ws,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if ret == -1 {
|
if ret == -1 || ws.ws_row == 0 || ws.ws_col == 0 {
|
||||||
ws.ws_row = 24;
|
ws.ws_row = 24;
|
||||||
ws.ws_col = 80;
|
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