Add CLI, config file support, and configurable lookback key
- Add clap-based CLI with comprehensive help - Add TOML config support (~/.config/claude-chill.toml) - Add key_parser for user-friendly key bindings ([ctrl][shift][j]) - Change default lookback key to Ctrl+Shift+J - Remove unused modules (analyzer, output_processor, script_parser) - Add MIT LICENSE file - Add disclaimer to README Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ target
|
|||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
.claude
|
||||||
|
|||||||
376
Cargo.lock
generated
376
Cargo.lock
generated
@@ -2,6 +2,56 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "0.6.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.100"
|
version = "1.0.100"
|
||||||
@@ -26,22 +76,148 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.5.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.5.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.5.49"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "0.7.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "claude-chill"
|
name = "claude-chill"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"dirs",
|
||||||
"libc",
|
"libc",
|
||||||
"memchr",
|
"memchr",
|
||||||
"nix",
|
"nix",
|
||||||
|
"serde",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.177"
|
version = "0.2.177"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -59,3 +235,203 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.105"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.43"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.114"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 David Beesley
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
54
README.md
54
README.md
@@ -1,14 +1,21 @@
|
|||||||
# claude-chill
|
# claude-chill
|
||||||
|
|
||||||
A PTY proxy that reduces terminal flicker by truncating synchronized output blocks.
|
A PTY proxy that tames Claude Code's massive terminal updates.
|
||||||
|
|
||||||
## The Problem
|
## The Problem
|
||||||
|
|
||||||
Claude Code (and similar tools) use synchronized output (`\x1b[?2026h` / `\x1b[?2026l`) to update the terminal atomically. When these blocks contain thousands of lines, some terminals struggle to render them smoothly, causing visible flicker.
|
Claude Code uses synchronized output to update the terminal atomically. It wraps output in sync markers (`\x1b[?2026h` ... `\x1b[?2026l`) so the terminal renders everything at once without flicker.
|
||||||
|
|
||||||
|
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 Solution
|
## The Solution
|
||||||
|
|
||||||
claude-chill sits between your terminal and the child process, intercepting synchronized output blocks and truncating them to a configurable number of lines. This keeps atomic updates small enough for smooth rendering while preserving full history for lookback.
|
claude-chill sits between your terminal and Claude Code:
|
||||||
|
|
||||||
|
1. **Intercepts sync blocks** - Catches those massive atomic updates
|
||||||
|
2. **Truncates output** - Sends only the last N lines (default: 100) to your terminal
|
||||||
|
3. **Preserves history** - Stores the full content in a buffer
|
||||||
|
4. **Enables lookback** - Press a key to dump the buffer, then scroll up
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -19,33 +26,36 @@ cargo install --path crates/claude-chill
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude-chill <command> [args...]
|
|
||||||
|
|
||||||
# Example: wrap Claude Code
|
|
||||||
claude-chill claude
|
claude-chill claude
|
||||||
|
claude-chill -- claude --verbose # Use -- for command flags
|
||||||
# Example: wrap any command
|
claude-chill -l 50 -- claude # Set max lines to 50
|
||||||
claude-chill bash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
## Lookback
|
||||||
|
|
||||||
- `CHILL_MAX_LINES` - Max lines per sync block (default: 100)
|
Press `Ctrl+Shift+J` to dump the history buffer to terminal. Scroll up to see it. The next update from Claude will resume normal display.
|
||||||
- `CHILL_HISTORY` - Max history lines for lookback (default: 100000)
|
|
||||||
|
|
||||||
### Lookback Mode
|
## Configuration
|
||||||
|
|
||||||
Press `Ctrl+Shift+PgUp` to dump the full history buffer to the terminal.
|
Create `~/.config/claude-chill.toml`:
|
||||||
|
|
||||||
## How It Works
|
```toml
|
||||||
|
max_lines = 100 # Lines shown per sync block
|
||||||
|
history_lines = 100000 # Lines stored for lookback
|
||||||
|
lookback_key = "[ctrl][shift][j]"
|
||||||
|
```
|
||||||
|
|
||||||
1. Creates a PTY pair and spawns the child process
|
### Key Format
|
||||||
2. Intercepts all output from the child
|
|
||||||
3. Detects synchronized output blocks (between `?2026h` and `?2026l`)
|
`[modifier][key]` - Examples: `[f12]`, `[ctrl][g]`, `[ctrl][shift][j]`
|
||||||
4. For "full redraw" blocks (containing clear screen + cursor home):
|
|
||||||
- Stores full content in history buffer
|
Modifiers: `[ctrl]`, `[shift]`, `[alt]`
|
||||||
- Outputs only the last N lines
|
|
||||||
5. Passes all other content through unchanged
|
Keys: `[a]`-`[z]`, `[f1]`-`[f12]`, `[pageup]`, `[pagedown]`, `[home]`, `[end]`, `[enter]`, `[tab]`, `[space]`, `[esc]`
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This tool was developed for personal convenience on Debian Linux. It works for me, but it hasn't been extensively tested across different terminals, operating systems, or edge cases. Don't use it to send anyone to space, perform surgery, or run critical infrastructure. If it breaks, you get to keep both pieces.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ path = "src/lib.rs"
|
|||||||
name = "claude-chill"
|
name = "claude-chill"
|
||||||
path = "src/bin.rs"
|
path = "src/bin.rs"
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "chill-analyzer"
|
|
||||||
path = "src/analyzer.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
dirs = "6"
|
||||||
memchr = "2"
|
memchr = "2"
|
||||||
nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"] }
|
nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
use claude_chill::escape_sequences::{SYNC_END, SYNC_START};
|
|
||||||
use claude_chill::script_parser::strip_script_wrapper;
|
|
||||||
use std::env;
|
|
||||||
use std::fs;
|
|
||||||
use std::process::ExitCode;
|
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
|
||||||
let args: Vec<String> = env::args().collect();
|
|
||||||
|
|
||||||
if args.len() != 3 {
|
|
||||||
eprintln!("Usage: chill-analyzer <original.raw> <chill-output.raw>");
|
|
||||||
eprintln!();
|
|
||||||
eprintln!("Compares two script recordings, stripping headers and showing diffs.");
|
|
||||||
return ExitCode::from(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let original_path = &args[1];
|
|
||||||
let pty_path = &args[2];
|
|
||||||
|
|
||||||
let original = match fs::read(original_path) {
|
|
||||||
Ok(data) => data,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to read {}: {}", original_path, e);
|
|
||||||
return ExitCode::from(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let pty_output = match fs::read(pty_path) {
|
|
||||||
Ok(data) => data,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to read {}: {}", pty_path, e);
|
|
||||||
return ExitCode::from(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let original_content = strip_script_wrapper(&original);
|
|
||||||
let pty_content = strip_script_wrapper(&pty_output);
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Original: {} bytes -> {} bytes after stripping",
|
|
||||||
original.len(),
|
|
||||||
original_content.len()
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"PTY: {} bytes -> {} bytes after stripping",
|
|
||||||
pty_output.len(),
|
|
||||||
pty_content.len()
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
compare_and_report(original_content, pty_content);
|
|
||||||
|
|
||||||
ExitCode::SUCCESS
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_and_report(original: &[u8], pty: &[u8]) {
|
|
||||||
let mut first_diff: Option<usize> = None;
|
|
||||||
let common_len = original.len().min(pty.len());
|
|
||||||
|
|
||||||
for i in 0..common_len {
|
|
||||||
if original[i] != pty[i] {
|
|
||||||
first_diff = Some(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match first_diff {
|
|
||||||
Some(pos) => {
|
|
||||||
println!("FIRST DIFFERENCE at byte {}", pos);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let context_start = pos.saturating_sub(32);
|
|
||||||
let context_end = (pos + 64).min(original.len()).min(pty.len());
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"=== ORIGINAL @ {} (diff at +{}) ===",
|
|
||||||
context_start,
|
|
||||||
pos - context_start
|
|
||||||
);
|
|
||||||
print_hex_with_highlight(
|
|
||||||
&original[context_start..context_end.min(original.len())],
|
|
||||||
pos - context_start,
|
|
||||||
);
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"=== PTY @ {} (diff at +{}) ===",
|
|
||||||
context_start,
|
|
||||||
pos - context_start
|
|
||||||
);
|
|
||||||
print_hex_with_highlight(
|
|
||||||
&pty[context_start..context_end.min(pty.len())],
|
|
||||||
pos - context_start,
|
|
||||||
);
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("Context around diff:");
|
|
||||||
println!(
|
|
||||||
" Original byte: 0x{:02x} ({:?})",
|
|
||||||
original[pos],
|
|
||||||
char::from(original[pos])
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" PTY byte: 0x{:02x} ({:?})",
|
|
||||||
pty[pos],
|
|
||||||
char::from(pty[pos])
|
|
||||||
);
|
|
||||||
|
|
||||||
let sync_before = count_sync_markers(&original[..pos]);
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"Sync blocks before diff: {} starts, {} ends",
|
|
||||||
sync_before.0, sync_before.1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if original.len() == pty.len() {
|
|
||||||
println!("FILES ARE IDENTICAL");
|
|
||||||
} else {
|
|
||||||
println!("Files match for {} bytes, then one ends early", common_len);
|
|
||||||
println!(" Original: {} bytes", original.len());
|
|
||||||
println!(" PTY: {} bytes", pty.len());
|
|
||||||
|
|
||||||
if original.len() > pty.len() {
|
|
||||||
println!();
|
|
||||||
println!("=== ORIGINAL CONTINUES WITH ===");
|
|
||||||
print_hex_with_highlight(
|
|
||||||
&original[common_len..(common_len + 64).min(original.len())],
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
println!();
|
|
||||||
println!("=== PTY CONTINUES WITH ===");
|
|
||||||
print_hex_with_highlight(&pty[common_len..(common_len + 64).min(pty.len())], 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("=== SYNC BLOCK SUMMARY ===");
|
|
||||||
let orig_sync = count_sync_markers(original);
|
|
||||||
let pty_sync = count_sync_markers(pty);
|
|
||||||
println!(
|
|
||||||
"Original: {} sync starts, {} sync ends",
|
|
||||||
orig_sync.0, orig_sync.1
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"PTY: {} sync starts, {} sync ends",
|
|
||||||
pty_sync.0, pty_sync.1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_sync_markers(data: &[u8]) -> (usize, usize) {
|
|
||||||
let mut starts = 0;
|
|
||||||
let mut ends = 0;
|
|
||||||
|
|
||||||
for i in 0..data.len().saturating_sub(SYNC_START.len()) {
|
|
||||||
if &data[i..i + SYNC_START.len()] == SYNC_START {
|
|
||||||
starts += 1;
|
|
||||||
}
|
|
||||||
if &data[i..i + SYNC_END.len()] == SYNC_END {
|
|
||||||
ends += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(starts, ends)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_hex_with_highlight(data: &[u8], highlight_pos: usize) {
|
|
||||||
for (i, chunk) in data.chunks(16).enumerate() {
|
|
||||||
let offset = i * 16;
|
|
||||||
print!("{:08x}: ", offset);
|
|
||||||
|
|
||||||
for (j, &byte) in chunk.iter().enumerate() {
|
|
||||||
let pos = offset + j;
|
|
||||||
if pos == highlight_pos {
|
|
||||||
print!("\x1b[41m{:02x}\x1b[0m ", byte);
|
|
||||||
} else {
|
|
||||||
print!("{:02x} ", byte);
|
|
||||||
}
|
|
||||||
if j == 7 {
|
|
||||||
print!(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in chunk.len()..16 {
|
|
||||||
print!(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
print!(" |");
|
|
||||||
for (j, &byte) in chunk.iter().enumerate() {
|
|
||||||
let pos = offset + j;
|
|
||||||
let c = if byte.is_ascii_graphic() || byte == b' ' {
|
|
||||||
byte as char
|
|
||||||
} else {
|
|
||||||
'.'
|
|
||||||
};
|
|
||||||
if pos == highlight_pos {
|
|
||||||
print!("\x1b[41m{}\x1b[0m", c);
|
|
||||||
} else {
|
|
||||||
print!("{}", c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!("|");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,42 @@
|
|||||||
use claude_chill::proxy::{Proxy, ProxyConfig};
|
mod cli;
|
||||||
use std::env;
|
|
||||||
use std::process::ExitCode;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
fn parse_env_var<T: FromStr>(key: &str, default: T) -> T {
|
use clap::Parser;
|
||||||
env::var(key)
|
use claude_chill::config::Config;
|
||||||
.ok()
|
use claude_chill::key_parser;
|
||||||
.and_then(|v| v.parse().ok())
|
use claude_chill::proxy::{Proxy, ProxyConfig};
|
||||||
.unwrap_or(default)
|
use std::process::ExitCode;
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let args: Vec<String> = env::args().collect();
|
let cli = cli::Cli::parse();
|
||||||
|
let config = Config::load();
|
||||||
|
|
||||||
if args.len() < 2 {
|
let max_lines = cli.max_lines.unwrap_or(config.max_lines);
|
||||||
eprintln!("Usage: claude-chill <command> [args...]");
|
let history_lines = cli.history_lines.unwrap_or(config.history_lines);
|
||||||
eprintln!();
|
|
||||||
eprintln!("PTY proxy that reduces terminal flicker by truncating synchronized output.");
|
let lookback_key = cli
|
||||||
eprintln!();
|
.lookback_key
|
||||||
eprintln!("Environment variables:");
|
.clone()
|
||||||
eprintln!(" CHILL_MAX_LINES Max lines per sync block (default: 100)");
|
.unwrap_or_else(|| config.lookback_key.clone());
|
||||||
eprintln!(" CHILL_HISTORY Max history lines for lookback (default: 100000)");
|
|
||||||
eprintln!();
|
let lookback_sequence = match key_parser::parse(&lookback_key) {
|
||||||
eprintln!("Lookback mode: Press Ctrl+Shift+PgUp to view full history");
|
Ok(key) => key.to_escape_sequence(),
|
||||||
return ExitCode::from(1);
|
Err(e) => {
|
||||||
|
eprintln!("Invalid lookback key '{}': {}", lookback_key, e);
|
||||||
|
eprintln!("Using default: [ctrl][shift][j]");
|
||||||
|
config.lookback_sequence()
|
||||||
}
|
}
|
||||||
|
|
||||||
let command = &args[1];
|
|
||||||
let cmd_args: Vec<&str> = args[2..].iter().map(|s| s.as_str()).collect();
|
|
||||||
|
|
||||||
let config = ProxyConfig {
|
|
||||||
max_output_lines: parse_env_var("CHILL_MAX_LINES", 100),
|
|
||||||
max_history_lines: parse_env_var("CHILL_HISTORY", 100_000),
|
|
||||||
..Default::default()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match Proxy::spawn(command, &cmd_args, config) {
|
let proxy_config = ProxyConfig {
|
||||||
|
max_output_lines: max_lines,
|
||||||
|
max_history_lines: history_lines,
|
||||||
|
lookback_key,
|
||||||
|
lookback_sequence,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cmd_args: Vec<&str> = cli.args.iter().map(|s| s.as_str()).collect();
|
||||||
|
|
||||||
|
match Proxy::spawn(&cli.command, &cmd_args, proxy_config) {
|
||||||
Ok(mut proxy) => match proxy.run() {
|
Ok(mut proxy) => match proxy.run() {
|
||||||
Ok(exit_code) => ExitCode::from(exit_code as u8),
|
Ok(exit_code) => ExitCode::from(exit_code as u8),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
63
crates/claude-chill/src/cli.rs
Normal file
63
crates/claude-chill/src/cli.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "claude-chill",
|
||||||
|
about = "PTY proxy that reduces terminal flicker by truncating synchronized output",
|
||||||
|
long_about = "claude-chill sits between your terminal and a child process, intercepting \
|
||||||
|
synchronized output blocks and truncating them to reduce flicker.\n\n\
|
||||||
|
Full history is preserved. Press the lookback key (default: Ctrl+Shift+J) \
|
||||||
|
to dump history to terminal, then scroll up to view it.",
|
||||||
|
version,
|
||||||
|
after_help = "USAGE EXAMPLES:\n \
|
||||||
|
claude-chill claude\n \
|
||||||
|
claude-chill -- claude --verbose # Use -- for command flags\n \
|
||||||
|
claude-chill -l 50 -- claude # Set max lines to 50\n\n\
|
||||||
|
CONFIGURATION:\n \
|
||||||
|
Create ~/.config/claude-chill.toml:\n\n \
|
||||||
|
max_lines = 100 # Lines shown per sync block\n \
|
||||||
|
history_lines = 100000 # Lines stored for lookback\n \
|
||||||
|
lookback_key = \"[ctrl][shift][j]\"\n\n\
|
||||||
|
KEY FORMAT: [modifier][key]\n \
|
||||||
|
Modifiers: [ctrl], [shift], [alt]\n \
|
||||||
|
Keys: [a]-[z], [f1]-[f12], [pageup], [enter], [space], etc."
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[arg(
|
||||||
|
help = "Command to run",
|
||||||
|
required = true,
|
||||||
|
value_name = "COMMAND"
|
||||||
|
)]
|
||||||
|
pub command: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
help = "Arguments passed to command (use -- before command flags)",
|
||||||
|
value_name = "ARGS",
|
||||||
|
trailing_var_arg = true
|
||||||
|
)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 'l',
|
||||||
|
long = "max-lines",
|
||||||
|
help = "Maximum lines per sync block",
|
||||||
|
value_name = "N"
|
||||||
|
)]
|
||||||
|
pub max_lines: Option<usize>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 'H',
|
||||||
|
long = "history",
|
||||||
|
help = "Maximum history lines for lookback",
|
||||||
|
value_name = "N"
|
||||||
|
)]
|
||||||
|
pub history_lines: Option<usize>,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
short = 'k',
|
||||||
|
long = "lookback-key",
|
||||||
|
help = "Key to trigger lookback (e.g., [ctrl][shift][j])",
|
||||||
|
value_name = "KEY"
|
||||||
|
)]
|
||||||
|
pub lookback_key: Option<String>,
|
||||||
|
}
|
||||||
100
crates/claude-chill/src/config.rs
Normal file
100
crates/claude-chill/src/config.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use crate::key_parser::{self, KeyCombination};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const DEFAULT_LOOKBACK_KEY: &str = "[ctrl][shift][j]";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub max_lines: usize,
|
||||||
|
pub history_lines: usize,
|
||||||
|
pub lookback_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_lines: 100,
|
||||||
|
history_lines: 100_000,
|
||||||
|
lookback_key: DEFAULT_LOOKBACK_KEY.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let config_path = Self::config_path();
|
||||||
|
match config_path {
|
||||||
|
Some(path) if path.exists() => Self::load_from_file(&path),
|
||||||
|
_ => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|d| d.join("claude-chill.toml"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_from_file(path: &PathBuf) -> Self {
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(content) => match toml::from_str(&content) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: Failed to parse config file {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: Failed to read config file {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_lookback_key(&self) -> Result<KeyCombination, key_parser::ParseKeyError> {
|
||||||
|
key_parser::parse(&self.lookback_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookback_sequence(&self) -> Vec<u8> {
|
||||||
|
self.parse_lookback_key()
|
||||||
|
.map(|k| k.to_escape_sequence())
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: Invalid lookback_key '{}': {}",
|
||||||
|
self.lookback_key, e
|
||||||
|
);
|
||||||
|
eprintln!("Using default: {}", DEFAULT_LOOKBACK_KEY);
|
||||||
|
key_parser::parse(DEFAULT_LOOKBACK_KEY)
|
||||||
|
.map(|k| k.to_escape_sequence())
|
||||||
|
.unwrap_or_else(|_| b"\x1b[5;6~".to_vec())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_config() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.max_lines, 100);
|
||||||
|
assert_eq!(config.history_lines, 100_000);
|
||||||
|
assert_eq!(config.lookback_key, "[ctrl][shift][j]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_lookback_sequence() {
|
||||||
|
let config = Config::default();
|
||||||
|
assert_eq!(config.lookback_sequence(), vec![0x0A]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ pub const SYNC_END: &[u8] = b"\x1b[?2026l";
|
|||||||
pub const CLEAR_SCREEN: &[u8] = b"\x1b[2J";
|
pub const CLEAR_SCREEN: &[u8] = b"\x1b[2J";
|
||||||
pub const CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J";
|
pub const CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J";
|
||||||
pub const CURSOR_HOME: &[u8] = b"\x1b[H";
|
pub const CURSOR_HOME: &[u8] = b"\x1b[H";
|
||||||
pub const LOOKBACK_HEADER: &[u8] = b"\x1b[7m--- LOOKBACK MODE ---\x1b[0m\r\n";
|
|
||||||
|
|
||||||
pub const SYNC_BUFFER_CAPACITY: usize = 1024 * 1024;
|
pub const SYNC_BUFFER_CAPACITY: usize = 1024 * 1024;
|
||||||
pub const PASSTHROUGH_BUFFER_CAPACITY: usize = 65536;
|
pub const PASSTHROUGH_BUFFER_CAPACITY: usize = 65536;
|
||||||
|
|||||||
386
crates/claude-chill/src/key_parser.rs
Normal file
386
crates/claude-chill/src/key_parser.rs
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParseKeyError {
|
||||||
|
pub raw: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParseKeyError {
|
||||||
|
pub fn new(raw: impl Into<String>, reason: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
raw: raw.into(),
|
||||||
|
reason: reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ParseKeyError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "cannot parse {:?} as key: {}", self.raw, self.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ParseKeyError {}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct Modifiers {
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub shift: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum KeyCode {
|
||||||
|
Char(char),
|
||||||
|
F(u8),
|
||||||
|
Enter,
|
||||||
|
Esc,
|
||||||
|
Tab,
|
||||||
|
Backspace,
|
||||||
|
Delete,
|
||||||
|
Insert,
|
||||||
|
Home,
|
||||||
|
End,
|
||||||
|
PageUp,
|
||||||
|
PageDown,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Space,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct KeyCombination {
|
||||||
|
pub code: KeyCode,
|
||||||
|
pub modifiers: Modifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyCombination {
|
||||||
|
pub fn to_escape_sequence(&self) -> Vec<u8> {
|
||||||
|
key_to_escape_sequence(&self.code, &self.modifiers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for KeyCombination {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
if self.modifiers.ctrl {
|
||||||
|
write!(f, "[ctrl]")?;
|
||||||
|
}
|
||||||
|
if self.modifiers.shift {
|
||||||
|
write!(f, "[shift]")?;
|
||||||
|
}
|
||||||
|
if self.modifiers.alt {
|
||||||
|
write!(f, "[alt]")?;
|
||||||
|
}
|
||||||
|
let key_name = match &self.code {
|
||||||
|
KeyCode::Char(c) => format!("[{}]", c),
|
||||||
|
KeyCode::F(n) => format!("[f{}]", n),
|
||||||
|
KeyCode::Enter => "[enter]".to_string(),
|
||||||
|
KeyCode::Esc => "[esc]".to_string(),
|
||||||
|
KeyCode::Tab => "[tab]".to_string(),
|
||||||
|
KeyCode::Backspace => "[backspace]".to_string(),
|
||||||
|
KeyCode::Delete => "[delete]".to_string(),
|
||||||
|
KeyCode::Insert => "[insert]".to_string(),
|
||||||
|
KeyCode::Home => "[home]".to_string(),
|
||||||
|
KeyCode::End => "[end]".to_string(),
|
||||||
|
KeyCode::PageUp => "[pageup]".to_string(),
|
||||||
|
KeyCode::PageDown => "[pagedown]".to_string(),
|
||||||
|
KeyCode::Up => "[up]".to_string(),
|
||||||
|
KeyCode::Down => "[down]".to_string(),
|
||||||
|
KeyCode::Left => "[left]".to_string(),
|
||||||
|
KeyCode::Right => "[right]".to_string(),
|
||||||
|
KeyCode::Space => "[space]".to_string(),
|
||||||
|
};
|
||||||
|
write!(f, "{}", key_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(raw: &str) -> Result<KeyCombination, ParseKeyError> {
|
||||||
|
let raw_lower = raw.to_ascii_lowercase();
|
||||||
|
let mut modifiers = Modifiers::default();
|
||||||
|
let mut key_code: Option<KeyCode> = None;
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
let chars: Vec<char> = raw_lower.chars().collect();
|
||||||
|
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] != '[' {
|
||||||
|
return Err(ParseKeyError::new(
|
||||||
|
raw,
|
||||||
|
format!("expected '[' at position {}", i),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = i + 1;
|
||||||
|
let mut end = start;
|
||||||
|
while end < chars.len() && chars[end] != ']' {
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if end >= chars.len() {
|
||||||
|
return Err(ParseKeyError::new(raw, "unclosed bracket"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: String = chars[start..end].iter().collect();
|
||||||
|
i = end + 1;
|
||||||
|
|
||||||
|
match token.as_str() {
|
||||||
|
"ctrl" | "control" => modifiers.ctrl = true,
|
||||||
|
"shift" => modifiers.shift = true,
|
||||||
|
"alt" => modifiers.alt = true,
|
||||||
|
_ => {
|
||||||
|
if key_code.is_some() {
|
||||||
|
return Err(ParseKeyError::new(raw, "multiple key codes specified"));
|
||||||
|
}
|
||||||
|
key_code = Some(parse_key_code(&token, raw)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match key_code {
|
||||||
|
Some(code) => Ok(KeyCombination { code, modifiers }),
|
||||||
|
None => Err(ParseKeyError::new(raw, "no key code specified")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_key_code(token: &str, raw: &str) -> Result<KeyCode, ParseKeyError> {
|
||||||
|
let code = match token {
|
||||||
|
"[" => KeyCode::Char('['),
|
||||||
|
"]" => KeyCode::Char(']'),
|
||||||
|
"esc" | "escape" => KeyCode::Esc,
|
||||||
|
"enter" | "return" => KeyCode::Enter,
|
||||||
|
"tab" => KeyCode::Tab,
|
||||||
|
"backspace" | "bs" => KeyCode::Backspace,
|
||||||
|
"delete" | "del" => KeyCode::Delete,
|
||||||
|
"insert" | "ins" => KeyCode::Insert,
|
||||||
|
"home" => KeyCode::Home,
|
||||||
|
"end" => KeyCode::End,
|
||||||
|
"pageup" | "pgup" => KeyCode::PageUp,
|
||||||
|
"pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
|
||||||
|
"up" => KeyCode::Up,
|
||||||
|
"down" => KeyCode::Down,
|
||||||
|
"left" => KeyCode::Left,
|
||||||
|
"right" => KeyCode::Right,
|
||||||
|
"space" => KeyCode::Space,
|
||||||
|
"f1" => KeyCode::F(1),
|
||||||
|
"f2" => KeyCode::F(2),
|
||||||
|
"f3" => KeyCode::F(3),
|
||||||
|
"f4" => KeyCode::F(4),
|
||||||
|
"f5" => KeyCode::F(5),
|
||||||
|
"f6" => KeyCode::F(6),
|
||||||
|
"f7" => KeyCode::F(7),
|
||||||
|
"f8" => KeyCode::F(8),
|
||||||
|
"f9" => KeyCode::F(9),
|
||||||
|
"f10" => KeyCode::F(10),
|
||||||
|
"f11" => KeyCode::F(11),
|
||||||
|
"f12" => KeyCode::F(12),
|
||||||
|
s if s.len() == 1 => KeyCode::Char(s.chars().next().unwrap_or(' ')),
|
||||||
|
_ => return Err(ParseKeyError::new(raw, format!("unknown key: {}", token))),
|
||||||
|
};
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_to_escape_sequence(code: &KeyCode, modifiers: &Modifiers) -> Vec<u8> {
|
||||||
|
let modifier_code = match (modifiers.ctrl, modifiers.shift, modifiers.alt) {
|
||||||
|
(false, false, false) => 0,
|
||||||
|
_ => 1 + modifiers.shift as u8 + (modifiers.alt as u8 * 2) + (modifiers.ctrl as u8 * 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
match code {
|
||||||
|
KeyCode::PageUp => modified_key(b"5", modifier_code),
|
||||||
|
KeyCode::PageDown => modified_key(b"6", modifier_code),
|
||||||
|
KeyCode::Home => {
|
||||||
|
if modifier_code == 0 {
|
||||||
|
b"\x1b[H".to_vec()
|
||||||
|
} else {
|
||||||
|
format!("\x1b[1;{}H", modifier_code).into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
if modifier_code == 0 {
|
||||||
|
b"\x1b[F".to_vec()
|
||||||
|
} else {
|
||||||
|
format!("\x1b[1;{}F", modifier_code).into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up => arrow_key(b'A', modifier_code),
|
||||||
|
KeyCode::Down => arrow_key(b'B', modifier_code),
|
||||||
|
KeyCode::Right => arrow_key(b'C', modifier_code),
|
||||||
|
KeyCode::Left => arrow_key(b'D', modifier_code),
|
||||||
|
KeyCode::Insert => modified_key(b"2", modifier_code),
|
||||||
|
KeyCode::Delete => modified_key(b"3", modifier_code),
|
||||||
|
KeyCode::F(n) => function_key(*n, modifier_code),
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if modifiers.alt {
|
||||||
|
b"\x1b\r".to_vec()
|
||||||
|
} else {
|
||||||
|
b"\r".to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Tab => {
|
||||||
|
if modifiers.shift {
|
||||||
|
b"\x1b[Z".to_vec()
|
||||||
|
} else {
|
||||||
|
b"\t".to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Esc => b"\x1b".to_vec(),
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if modifiers.ctrl {
|
||||||
|
vec![0x08]
|
||||||
|
} else {
|
||||||
|
vec![0x7f]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Space => {
|
||||||
|
if modifiers.ctrl {
|
||||||
|
vec![0x00]
|
||||||
|
} else {
|
||||||
|
b" ".to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if modifiers.ctrl && c.is_ascii_alphabetic() {
|
||||||
|
let ctrl_char = (c.to_ascii_uppercase() as u8) - b'A' + 1;
|
||||||
|
if modifiers.alt {
|
||||||
|
vec![0x1b, ctrl_char]
|
||||||
|
} else {
|
||||||
|
vec![ctrl_char]
|
||||||
|
}
|
||||||
|
} else if modifiers.alt {
|
||||||
|
vec![0x1b, *c as u8]
|
||||||
|
} else if modifiers.shift {
|
||||||
|
vec![c.to_ascii_uppercase() as u8]
|
||||||
|
} else {
|
||||||
|
vec![*c as u8]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modified_key(base: &[u8], modifier: u8) -> Vec<u8> {
|
||||||
|
if modifier == 0 {
|
||||||
|
format!("\x1b[{}~", std::str::from_utf8(base).unwrap_or("")).into_bytes()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"\x1b[{};{}~",
|
||||||
|
std::str::from_utf8(base).unwrap_or(""),
|
||||||
|
modifier
|
||||||
|
)
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrow_key(direction: u8, modifier: u8) -> Vec<u8> {
|
||||||
|
if modifier == 0 {
|
||||||
|
vec![0x1b, b'[', direction]
|
||||||
|
} else {
|
||||||
|
format!("\x1b[1;{}{}", modifier, direction as char).into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn function_key(n: u8, modifier: u8) -> Vec<u8> {
|
||||||
|
let code = match n {
|
||||||
|
1 => 11,
|
||||||
|
2 => 12,
|
||||||
|
3 => 13,
|
||||||
|
4 => 14,
|
||||||
|
5 => 15,
|
||||||
|
6 => 17,
|
||||||
|
7 => 18,
|
||||||
|
8 => 19,
|
||||||
|
9 => 20,
|
||||||
|
10 => 21,
|
||||||
|
11 => 23,
|
||||||
|
12 => 24,
|
||||||
|
_ => return b"\x1b[24~".to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if modifier == 0 {
|
||||||
|
format!("\x1b[{}~", code).into_bytes()
|
||||||
|
} else {
|
||||||
|
format!("\x1b[{};{}~", code, modifier).into_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_simple_key() {
|
||||||
|
let key = parse("[f12]").unwrap();
|
||||||
|
assert_eq!(key.code, KeyCode::F(12));
|
||||||
|
assert!(!key.modifiers.ctrl);
|
||||||
|
assert!(!key.modifiers.shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_ctrl_shift_pageup() {
|
||||||
|
let key = parse("[ctrl][shift][pageup]").unwrap();
|
||||||
|
assert_eq!(key.code, KeyCode::PageUp);
|
||||||
|
assert!(key.modifiers.ctrl);
|
||||||
|
assert!(key.modifiers.shift);
|
||||||
|
assert!(!key.modifiers.alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_alt_enter() {
|
||||||
|
let key = parse("[alt][enter]").unwrap();
|
||||||
|
assert_eq!(key.code, KeyCode::Enter);
|
||||||
|
assert!(key.modifiers.alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_single_char() {
|
||||||
|
let key = parse("[ctrl][c]").unwrap();
|
||||||
|
assert_eq!(key.code, KeyCode::Char('c'));
|
||||||
|
assert!(key.modifiers.ctrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_escape_sequence_ctrl_shift_pageup() {
|
||||||
|
let key = parse("[ctrl][shift][pageup]").unwrap();
|
||||||
|
assert_eq!(key.to_escape_sequence(), b"\x1b[5;6~".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_escape_sequence_f12() {
|
||||||
|
let key = parse("[f12]").unwrap();
|
||||||
|
assert_eq!(key.to_escape_sequence(), b"\x1b[24~".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_escape_sequence_ctrl_c() {
|
||||||
|
let key = parse("[ctrl][c]").unwrap();
|
||||||
|
assert_eq!(key.to_escape_sequence(), vec![0x03]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display() {
|
||||||
|
let key = parse("[ctrl][shift][pageup]").unwrap();
|
||||||
|
assert_eq!(key.to_string(), "[ctrl][shift][pageup]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_case_insensitive() {
|
||||||
|
let key = parse("[CTRL][SHIFT][PAGEUP]").unwrap();
|
||||||
|
assert_eq!(key.code, KeyCode::PageUp);
|
||||||
|
assert!(key.modifiers.ctrl);
|
||||||
|
assert!(key.modifiers.shift);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_unclosed_bracket() {
|
||||||
|
let result = parse("[ctrl");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_no_key() {
|
||||||
|
let result = parse("[ctrl][shift]");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
pub mod config;
|
||||||
pub mod escape_parser;
|
pub mod escape_parser;
|
||||||
pub mod escape_sequences;
|
pub mod escape_sequences;
|
||||||
|
pub mod key_parser;
|
||||||
pub mod line_buffer;
|
pub mod line_buffer;
|
||||||
pub mod output_processor;
|
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
pub mod script_parser;
|
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
use crate::escape_parser::{EscapeParser, ParsedEscape};
|
|
||||||
use crate::escape_sequences::{
|
|
||||||
PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY, SYNC_END, SYNC_START,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct OutputProcessor {
|
|
||||||
parser: EscapeParser,
|
|
||||||
in_sync_block: bool,
|
|
||||||
sync_buffer: Vec<u8>,
|
|
||||||
passthrough_buffer: Vec<u8>,
|
|
||||||
pending_escape: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for OutputProcessor {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OutputProcessor {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
parser: EscapeParser::new(),
|
|
||||||
in_sync_block: false,
|
|
||||||
sync_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY),
|
|
||||||
passthrough_buffer: Vec::with_capacity(PASSTHROUGH_BUFFER_CAPACITY),
|
|
||||||
pending_escape: Vec::with_capacity(PENDING_ESCAPE_CAPACITY),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn process(&mut self, data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut output = Vec::new();
|
|
||||||
self.passthrough_buffer.clear();
|
|
||||||
|
|
||||||
for &byte in data {
|
|
||||||
let in_escape = self.parser.in_escape_sequence();
|
|
||||||
|
|
||||||
if in_escape && self.pending_escape.is_empty() {
|
|
||||||
self.pending_escape.push(0x1b);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(event) = self.parser.feed(byte) {
|
|
||||||
match event {
|
|
||||||
ParsedEscape::SyncStart => {
|
|
||||||
if !self.passthrough_buffer.is_empty() {
|
|
||||||
output.extend_from_slice(&self.passthrough_buffer);
|
|
||||||
self.passthrough_buffer.clear();
|
|
||||||
}
|
|
||||||
self.pending_escape.clear();
|
|
||||||
self.in_sync_block = true;
|
|
||||||
self.sync_buffer.clear();
|
|
||||||
self.sync_buffer.extend_from_slice(SYNC_START);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ParsedEscape::SyncEnd => {
|
|
||||||
self.pending_escape.clear();
|
|
||||||
if self.in_sync_block {
|
|
||||||
self.sync_buffer.extend_from_slice(SYNC_END);
|
|
||||||
output.extend_from_slice(&self.sync_buffer);
|
|
||||||
self.in_sync_block = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
self.flush_pending_escape();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.parser.in_escape_sequence() && !self.pending_escape.is_empty() {
|
|
||||||
self.flush_pending_escape();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.parser.in_escape_sequence() {
|
|
||||||
self.pending_escape.push(byte);
|
|
||||||
} else if self.in_sync_block {
|
|
||||||
self.sync_buffer.push(byte);
|
|
||||||
} else {
|
|
||||||
self.passthrough_buffer.push(byte);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.pending_escape.is_empty() {
|
|
||||||
self.flush_pending_escape();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.passthrough_buffer.is_empty() {
|
|
||||||
output.extend_from_slice(&self.passthrough_buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flush_pending_escape(&mut self) {
|
|
||||||
if self.in_sync_block {
|
|
||||||
self.sync_buffer.extend_from_slice(&self.pending_escape);
|
|
||||||
} else {
|
|
||||||
self.passthrough_buffer
|
|
||||||
.extend_from_slice(&self.pending_escape);
|
|
||||||
}
|
|
||||||
self.pending_escape.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_passthrough_no_sync() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"hello world\r\n";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(output, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_single_sync_block() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"\x1b[?2026hcontent\x1b[?2026l";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(output, input, "Sync block should pass through unchanged");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sync_start_no_duplicate_byte() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"\x1b[?2026hcontent\x1b[?2026l";
|
|
||||||
let output = processor.process(input);
|
|
||||||
|
|
||||||
let sync_start_count = output
|
|
||||||
.windows(SYNC_START.len())
|
|
||||||
.filter(|w| *w == SYNC_START)
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
|
||||||
sync_start_count, 1,
|
|
||||||
"Should have exactly one SYNC_START, got {}",
|
|
||||||
sync_start_count
|
|
||||||
);
|
|
||||||
|
|
||||||
let sync_end_count = output
|
|
||||||
.windows(SYNC_END.len())
|
|
||||||
.filter(|w| *w == SYNC_END)
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
|
||||||
sync_end_count, 1,
|
|
||||||
"Should have exactly one SYNC_END, got {}",
|
|
||||||
sync_end_count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_content_before_sync() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"before\x1b[?2026hcontent\x1b[?2026lafter";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(output, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_multiple_sync_blocks() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"\x1b[?2026hblock1\x1b[?2026l\x1b[?2026hblock2\x1b[?2026l";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(output, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_carriage_return_preserved() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"line1\r\nline2\r\n";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(output, input, "Carriage returns must be preserved");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_carriage_return_after_sync_start() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"\x1b[?2026h\r\ncontent\x1b[?2026l";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(
|
|
||||||
output, input,
|
|
||||||
"Carriage return after sync start must be preserved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_carriage_return_in_sync_block() {
|
|
||||||
let mut processor = OutputProcessor::new();
|
|
||||||
let input = b"\x1b[?2026hline1\r\nline2\r\n\x1b[?2026l";
|
|
||||||
let output = processor.process(input);
|
|
||||||
assert_eq!(
|
|
||||||
output, input,
|
|
||||||
"Carriage returns inside sync block must be preserved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::escape_parser::{EscapeParser, ParsedEscape};
|
use crate::escape_parser::{EscapeParser, ParsedEscape};
|
||||||
use crate::escape_sequences::{
|
use crate::escape_sequences::{
|
||||||
CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, LOOKBACK_HEADER,
|
CLEAR_SCREEN, CLEAR_SCROLLBACK, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY,
|
||||||
OUTPUT_BUFFER_CAPACITY, PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY,
|
PASSTHROUGH_BUFFER_CAPACITY, PENDING_ESCAPE_CAPACITY, SYNC_BUFFER_CAPACITY, SYNC_END,
|
||||||
SYNC_BUFFER_CAPACITY, SYNC_END, SYNC_START,
|
SYNC_START,
|
||||||
};
|
};
|
||||||
use crate::line_buffer::LineBuffer;
|
use crate::line_buffer::LineBuffer;
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -36,6 +36,7 @@ extern "C" fn handle_sigterm(_: libc::c_int) {
|
|||||||
pub struct ProxyConfig {
|
pub struct ProxyConfig {
|
||||||
pub max_output_lines: usize,
|
pub max_output_lines: usize,
|
||||||
pub max_history_lines: usize,
|
pub max_history_lines: usize,
|
||||||
|
pub lookback_key: String,
|
||||||
pub lookback_sequence: Vec<u8>,
|
pub lookback_sequence: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ impl Default for ProxyConfig {
|
|||||||
Self {
|
Self {
|
||||||
max_output_lines: 100,
|
max_output_lines: 100,
|
||||||
max_history_lines: 100_000,
|
max_history_lines: 100_000,
|
||||||
lookback_sequence: b"\x1b[5;6~".to_vec(),
|
lookback_key: "[ctrl][shift][j]".to_string(),
|
||||||
|
lookback_sequence: vec![0x0A],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,7 +354,8 @@ impl Proxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn enter_lookback_mode(&mut self, stdout_fd: i32) -> Result<()> {
|
fn enter_lookback_mode(&mut self, stdout_fd: i32) -> Result<()> {
|
||||||
write_all_raw(stdout_fd, LOOKBACK_HEADER)?;
|
let header = b"\x1b[7m--- LOOKBACK: scroll up to see history ---\x1b[0m\r\n";
|
||||||
|
write_all_raw(stdout_fd, header)?;
|
||||||
self.output_buffer.clear();
|
self.output_buffer.clear();
|
||||||
self.history.append_all(&mut self.output_buffer);
|
self.history.append_all(&mut self.output_buffer);
|
||||||
write_all_raw(stdout_fd, &self.output_buffer)?;
|
write_all_raw(stdout_fd, &self.output_buffer)?;
|
||||||
@@ -426,10 +429,7 @@ fn setup_raw_mode() -> Result<Option<Termios>> {
|
|||||||
Ok(Some(original))
|
Ok(Some(original))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_signal_handler(
|
fn setup_signal_handler(signal: Signal, handler: extern "C" fn(libc::c_int)) -> Result<()> {
|
||||||
signal: Signal,
|
|
||||||
handler: extern "C" fn(libc::c_int),
|
|
||||||
) -> Result<()> {
|
|
||||||
let action = SigAction::new(
|
let action = SigAction::new(
|
||||||
SigHandler::Handler(handler),
|
SigHandler::Handler(handler),
|
||||||
SaFlags::SA_RESTART,
|
SaFlags::SA_RESTART,
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
pub fn find_script_header_end(data: &[u8]) -> usize {
|
|
||||||
if !data.starts_with(b"Script started on") {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
for (i, &byte) in data.iter().enumerate() {
|
|
||||||
if byte == b'\n' {
|
|
||||||
return i + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find_script_footer_start(data: &[u8]) -> usize {
|
|
||||||
let footer_marker = b"\nScript done on";
|
|
||||||
for i in (0..data.len().saturating_sub(footer_marker.len())).rev() {
|
|
||||||
if &data[i..i + footer_marker.len()] == footer_marker {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn strip_script_wrapper(data: &[u8]) -> &[u8] {
|
|
||||||
let mut result = data;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let header_end = find_script_header_end(result);
|
|
||||||
if header_end == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
result = &result[header_end..];
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let footer_start = find_script_footer_start(result);
|
|
||||||
if footer_start >= result.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
result = &result[..footer_start];
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_header_end_no_header() {
|
|
||||||
let data = b"hello world";
|
|
||||||
assert_eq!(find_script_header_end(data), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_header_end_with_header() {
|
|
||||||
let data = b"Script started on 2024-01-01\ncontent here";
|
|
||||||
assert_eq!(find_script_header_end(data), 29);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_footer_start_no_footer() {
|
|
||||||
let data = b"hello world";
|
|
||||||
assert_eq!(find_script_footer_start(data), data.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_footer_start_with_footer() {
|
|
||||||
let data = b"content here\nScript done on 2024-01-01";
|
|
||||||
assert_eq!(find_script_footer_start(data), 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_script_wrapper() {
|
|
||||||
let data = b"Script started on 2024-01-01\ncontent\nScript done on 2024-01-01";
|
|
||||||
let stripped = strip_script_wrapper(data);
|
|
||||||
assert_eq!(stripped, b"content");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user