Fix Ghostty terminal support (#26)

This commit is contained in:
David Beesley
2026-01-25 23:46:22 -07:00
committed by GitHub
parent e2b20dce1c
commit b821abdd2a
7 changed files with 1737 additions and 335 deletions

780
Cargo.lock generated
View File

@@ -73,12 +73,75 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "atomic"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340"
dependencies = [
"bytemuck",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -122,7 +185,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -133,7 +196,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "claude-chill"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"anyhow",
"clap",
@@ -142,8 +205,9 @@ dependencies = [
"libc",
"log",
"memchr",
"nix",
"nix 0.30.1",
"serde",
"termwiz",
"toml",
"vt100",
]
@@ -154,6 +218,51 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "csscolorparser"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf"
dependencies = [
"lab",
"phf",
]
[[package]]
name = "deltae"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -204,6 +313,74 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "euclid"
version = "0.22.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
dependencies = [
"num-traits",
]
[[package]]
name = "fancy-regex"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2"
dependencies = [
"bit-set",
"regex",
]
[[package]]
name = "filedescriptor"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d"
dependencies = [
"libc",
"thiserror 1.0.69",
"winapi",
]
[[package]]
name = "finl_unicode"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5"
[[package]]
name = "fixedbitset"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -215,6 +392,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -227,6 +416,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -270,9 +465,31 @@ checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
name = "js-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "lab"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.177"
@@ -285,7 +502,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"libc",
]
@@ -295,24 +512,104 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mac_address"
version = "1.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303"
dependencies = [
"nix 0.29.0",
"winapi",
]
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memmem"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -325,6 +622,110 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
]
[[package]]
name = "pest"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "pest_meta"
version = "2.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "portable-atomic"
version = "1.13.0"
@@ -358,15 +759,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom",
"getrandom 0.2.17",
"libredox",
"thiserror",
"thiserror 2.0.17",
]
[[package]]
@@ -398,6 +820,12 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
version = "1.0.228"
@@ -425,7 +853,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -437,12 +865,60 @@ dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.114"
@@ -454,13 +930,96 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "terminfo"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
dependencies = [
"fnv",
"nom",
"phf",
"phf_codegen",
]
[[package]]
name = "termios"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
dependencies = [
"libc",
]
[[package]]
name = "termwiz"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [
"anyhow",
"base64",
"bitflags 2.10.0",
"fancy-regex",
"filedescriptor",
"finl_unicode",
"fixedbitset",
"hex",
"lazy_static",
"libc",
"log",
"memmem",
"nix 0.29.0",
"num-derive",
"num-traits",
"ordered-float",
"pest",
"pest_derive",
"phf",
"sha2",
"signal-hook",
"siphasher",
"terminfo",
"termios",
"thiserror 1.0.69",
"ucd-trie",
"unicode-segmentation",
"vtparse",
"wezterm-bidi",
"wezterm-blob-leases",
"wezterm-color-types",
"wezterm-dynamic",
"wezterm-input-types",
"winapi",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.17",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
@@ -471,7 +1030,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 2.0.114",
]
[[package]]
@@ -515,12 +1074,30 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -533,6 +1110,24 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
dependencies = [
"atomic",
"getrandom 0.3.4",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vt100"
version = "0.16.2"
@@ -554,12 +1149,169 @@ dependencies = [
"memchr",
]
[[package]]
name = "vtparse"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0"
dependencies = [
"utf8parse",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.114",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec"
dependencies = [
"log",
"wezterm-dynamic",
]
[[package]]
name = "wezterm-blob-leases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7"
dependencies = [
"getrandom 0.3.4",
"mac_address",
"sha2",
"thiserror 1.0.69",
"uuid",
]
[[package]]
name = "wezterm-color-types"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296"
dependencies = [
"csscolorparser",
"deltae",
"lazy_static",
"wezterm-dynamic",
]
[[package]]
name = "wezterm-dynamic"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac"
dependencies = [
"log",
"ordered-float",
"strsim",
"thiserror 1.0.69",
"wezterm-dynamic-derive",
]
[[package]]
name = "wezterm-dynamic-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "wezterm-input-types"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e"
dependencies = [
"bitflags 1.3.2",
"euclid",
"lazy_static",
"serde",
"wezterm-dynamic",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -583,3 +1335,9 @@ checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"

View File

@@ -5,6 +5,6 @@ members = [
]
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2024"
authors = ["David Beesley"]

View File

@@ -2,7 +2,7 @@
[![CI](https://github.com/davidbeesley/claude-chill/actions/workflows/ci.yml/badge.svg)](https://github.com/davidbeesley/claude-chill/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
![Version](https://img.shields.io/badge/version-0.1.3-blue)
![Version](https://img.shields.io/badge/version-0.1.4-blue)
![Linux](https://img.shields.io/badge/Linux-supported-green)
![macOS](https://img.shields.io/badge/macOS-supported-green)
![Windows](https://img.shields.io/badge/Windows-unsupported-red)

View File

@@ -26,5 +26,6 @@ libc = "0.2"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
vt100 = "0.16"
termwiz = "0.23"
log = "0.4"
env_logger = "0.11"

View File

@@ -0,0 +1,580 @@
//! Whitelist-based filter for terminal escape sequences going into history.
//!
//! Uses termwiz to parse escape sequences and only allows known-safe
//! visual sequences through. This prevents mode-setting sequences
//! (focus reporting, mouse tracking, etc.) from being replayed.
//!
//! IMPORTANT: Every enum variant must be explicitly classified.
//! No catch-all fallbacks - we must consciously decide on each case.
use std::fmt::Write as FmtWrite;
use termwiz::escape::Action;
use termwiz::escape::csi::CSI;
use termwiz::escape::parser::Parser;
/// Filter that only allows safe visual sequences into history.
pub struct HistoryFilter {
parser: Parser,
}
impl Default for HistoryFilter {
fn default() -> Self {
Self::new()
}
}
impl HistoryFilter {
pub fn new() -> Self {
Self {
parser: Parser::new(),
}
}
/// Filter bytes, returning only safe sequences for history.
pub fn filter(&mut self, input: &[u8]) -> Vec<u8> {
let actions = self.parser.parse_as_vec(input);
let mut output = String::new();
for action in actions {
if is_safe_for_history(&action) {
// Re-encode the action
let _ = write!(output, "{}", action);
}
}
output.into_bytes()
}
}
/// Classification result - explicit whitelist or blacklist
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Classification {
/// Safe for history - purely visual, no side effects
Whitelist,
/// Unsafe for history - triggers responses or enables modes
Blacklist,
}
/// Check if an action is safe to include in history.
fn is_safe_for_history(action: &Action) -> bool {
classify_action(action) == Classification::Whitelist
}
/// Classify an action as whitelist or blacklist.
/// Every variant must be explicitly handled - no wildcards.
fn classify_action(action: &Action) -> Classification {
use Classification::*;
match action {
// === WHITELIST: Text output ===
Action::Print(_) => Whitelist,
Action::PrintString(_) => Whitelist,
// === WHITELIST: Graphics data ===
Action::Sixel(_) => Whitelist,
Action::KittyImage(_) => Whitelist,
// === MIXED: CSI sequences - check subcategory ===
Action::CSI(csi) => classify_csi(csi),
// === MIXED: Control codes - check individually ===
Action::Control(code) => classify_control(*code),
// === MIXED: Escape sequences - check individually ===
Action::Esc(esc) => classify_esc(esc),
// === MIXED: OSC commands - check for queries ===
Action::OperatingSystemCommand(osc) => classify_osc(osc),
// === BLACKLIST: Device control (DCS) - queries and modes ===
Action::DeviceControl(_) => Blacklist,
// === BLACKLIST: Termcap query ===
Action::XtGetTcap(_) => Blacklist,
}
}
/// Classify CSI sequences - every variant explicitly handled.
fn classify_csi(csi: &CSI) -> Classification {
use Classification::*;
match csi {
// === WHITELIST: SGR (colors, styling) ===
CSI::Sgr(_) => Whitelist,
// === WHITELIST: Cursor movement ===
CSI::Cursor(_) => Whitelist,
// === WHITELIST: Edit operations ===
CSI::Edit(_) => Whitelist,
// === WHITELIST: Character path (bidi) ===
CSI::SelectCharacterPath(_, _) => Whitelist,
// === BLACKLIST: Mode setting (DECSET/DECRST) ===
// Includes focus tracking, mouse modes, bracketed paste, etc.
CSI::Mode(_) => Blacklist,
// === BLACKLIST: Device queries ===
CSI::Device(_) => Blacklist,
// === BLACKLIST: Keyboard protocol ===
CSI::Keyboard(_) => Blacklist,
// === BLACKLIST: Mouse reports ===
// These come IN from terminal, shouldn't be in output anyway
CSI::Mouse(_) => Blacklist,
// === MIXED: Window operations ===
CSI::Window(w) => classify_window(w),
// === BLACKLIST: Unspecified/unknown ===
// We can't know if it's safe, so blacklist
CSI::Unspecified(_) => Blacklist,
}
}
/// Classify window operations - every variant explicitly handled.
fn classify_window(window: &termwiz::escape::csi::Window) -> Classification {
use Classification::*;
use termwiz::escape::csi::Window;
match window {
// === WHITELIST: Window state modifications ===
Window::DeIconify => Whitelist,
Window::Iconify => Whitelist,
Window::MoveWindow { .. } => Whitelist,
Window::ResizeWindowPixels { .. } => Whitelist,
Window::RaiseWindow => Whitelist,
Window::LowerWindow => Whitelist,
Window::RefreshWindow => Whitelist,
Window::ResizeWindowCells { .. } => Whitelist,
Window::RestoreMaximizedWindow => Whitelist,
Window::MaximizeWindow => Whitelist,
Window::MaximizeWindowVertically => Whitelist,
Window::MaximizeWindowHorizontally => Whitelist,
Window::UndoFullScreenMode => Whitelist,
Window::ChangeToFullScreenMode => Whitelist,
Window::ToggleFullScreen => Whitelist,
// === WHITELIST: Title stack operations ===
Window::PushIconAndWindowTitle => Whitelist,
Window::PushIconTitle => Whitelist,
Window::PushWindowTitle => Whitelist,
Window::PopIconAndWindowTitle => Whitelist,
Window::PopIconTitle => Whitelist,
Window::PopWindowTitle => Whitelist,
// === BLACKLIST: Query operations (terminal sends response) ===
Window::ReportWindowState => Blacklist,
Window::ReportWindowPosition => Blacklist,
Window::ReportTextAreaPosition => Blacklist,
Window::ReportTextAreaSizePixels => Blacklist,
Window::ReportWindowSizePixels => Blacklist,
Window::ReportScreenSizePixels => Blacklist,
Window::ReportCellSizePixels => Blacklist,
Window::ReportCellSizePixelsResponse { .. } => Blacklist,
Window::ReportTextAreaSizeCells => Blacklist,
Window::ReportScreenSizeCells => Blacklist,
Window::ReportIconLabel => Blacklist,
Window::ReportWindowTitle => Blacklist,
Window::ChecksumRectangularArea { .. } => Blacklist,
}
}
/// Classify control codes - every variant explicitly handled.
fn classify_control(code: termwiz::escape::ControlCode) -> Classification {
use Classification::*;
use termwiz::escape::ControlCode;
match code {
// === WHITELIST: Common formatting controls ===
ControlCode::Null => Whitelist,
ControlCode::Bell => Whitelist,
ControlCode::Backspace => Whitelist,
ControlCode::HorizontalTab => Whitelist,
ControlCode::LineFeed => Whitelist,
ControlCode::VerticalTab => Whitelist,
ControlCode::FormFeed => Whitelist,
ControlCode::CarriageReturn => Whitelist,
ControlCode::ShiftOut => Whitelist,
ControlCode::ShiftIn => Whitelist,
// === BLACKLIST: ENQ triggers terminal response ===
ControlCode::Enquiry => Blacklist,
// === BLACKLIST: Other C0 controls ===
ControlCode::StartOfHeading => Blacklist,
ControlCode::StartOfText => Blacklist,
ControlCode::EndOfText => Blacklist,
ControlCode::EndOfTransmission => Blacklist,
ControlCode::Acknowledge => Blacklist,
ControlCode::DataLinkEscape => Blacklist,
ControlCode::DeviceControlOne => Blacklist,
ControlCode::DeviceControlTwo => Blacklist,
ControlCode::DeviceControlThree => Blacklist,
ControlCode::DeviceControlFour => Blacklist,
ControlCode::NegativeAcknowledge => Blacklist,
ControlCode::SynchronousIdle => Blacklist,
ControlCode::EndOfTransmissionBlock => Blacklist,
ControlCode::Cancel => Blacklist,
ControlCode::EndOfMedium => Blacklist,
ControlCode::Substitute => Blacklist,
ControlCode::Escape => Blacklist, // ESC alone shouldn't appear
ControlCode::FileSeparator => Blacklist,
ControlCode::GroupSeparator => Blacklist,
ControlCode::RecordSeparator => Blacklist,
ControlCode::UnitSeparator => Blacklist,
// === BLACKLIST: C1 8-bit controls ===
ControlCode::BPH => Blacklist,
ControlCode::NBH => Blacklist,
ControlCode::IND => Blacklist, // Use ESC D instead
ControlCode::NEL => Blacklist, // Use ESC E instead
ControlCode::SSA => Blacklist,
ControlCode::ESA => Blacklist,
ControlCode::HTS => Blacklist, // Use ESC H instead
ControlCode::HTJ => Blacklist,
ControlCode::VTS => Blacklist,
ControlCode::PLD => Blacklist,
ControlCode::PLU => Blacklist,
ControlCode::RI => Blacklist, // Use ESC M instead
ControlCode::SS2 => Blacklist,
ControlCode::SS3 => Blacklist,
ControlCode::DCS => Blacklist, // Device control string
ControlCode::PU1 => Blacklist,
ControlCode::PU2 => Blacklist,
ControlCode::STS => Blacklist,
ControlCode::CCH => Blacklist,
ControlCode::MW => Blacklist,
ControlCode::SPA => Blacklist,
ControlCode::EPA => Blacklist,
ControlCode::SOS => Blacklist,
ControlCode::SCI => Blacklist,
ControlCode::CSI => Blacklist, // CSI alone shouldn't appear
ControlCode::ST => Blacklist, // String terminator
ControlCode::OSC => Blacklist, // OSC alone shouldn't appear
ControlCode::PM => Blacklist,
ControlCode::APC => Blacklist,
}
}
/// Classify escape sequences - every variant explicitly handled.
fn classify_esc(esc: &termwiz::escape::Esc) -> Classification {
use Classification::*;
use termwiz::escape::{Esc, EscCode};
match esc {
Esc::Code(code) => match code {
// === WHITELIST: Cursor save/restore ===
EscCode::DecSaveCursorPosition => Whitelist,
EscCode::DecRestoreCursorPosition => Whitelist,
// === WHITELIST: Character set designation (G0) ===
EscCode::DecLineDrawingG0 => Whitelist,
EscCode::AsciiCharacterSetG0 => Whitelist,
EscCode::UkCharacterSetG0 => Whitelist,
// === WHITELIST: Character set designation (G1) ===
EscCode::DecLineDrawingG1 => Whitelist,
EscCode::AsciiCharacterSetG1 => Whitelist,
EscCode::UkCharacterSetG1 => Whitelist,
// === WHITELIST: Line operations ===
EscCode::Index => Whitelist,
EscCode::ReverseIndex => Whitelist,
EscCode::NextLine => Whitelist,
// === WHITELIST: Tab set ===
EscCode::HorizontalTabSet => Whitelist,
// === WHITELIST: Keypad modes ===
EscCode::DecApplicationKeyPad => Whitelist,
EscCode::DecNormalKeyPad => Whitelist,
// === WHITELIST: String terminator ===
EscCode::StringTerminator => Whitelist,
// === WHITELIST: Full reset ===
EscCode::FullReset => Whitelist,
// === WHITELIST: Tmux title ===
EscCode::TmuxTitle => Whitelist,
// === WHITELIST: Cursor position ===
EscCode::CursorPositionLowerLeft => Whitelist,
// === BLACKLIST: Single shifts ===
EscCode::SingleShiftG2 => Blacklist,
EscCode::SingleShiftG3 => Blacklist,
// === BLACKLIST: Guarded area ===
EscCode::StartOfGuardedArea => Blacklist,
EscCode::EndOfGuardedArea => Blacklist,
// === BLACKLIST: Start of string ===
EscCode::StartOfString => Blacklist,
// === BLACKLIST: Return terminal ID (query) ===
EscCode::ReturnTerminalId => Blacklist,
// === BLACKLIST: Privacy message ===
EscCode::PrivacyMessage => Blacklist,
// === BLACKLIST: Application program command ===
EscCode::ApplicationProgramCommand => Blacklist,
// === BLACKLIST: Back index ===
EscCode::DecBackIndex => Blacklist,
// === BLACKLIST: DEC line width/height ===
EscCode::DecDoubleHeightTopHalfLine => Blacklist,
EscCode::DecDoubleHeightBottomHalfLine => Blacklist,
EscCode::DecSingleWidthLine => Blacklist,
EscCode::DecDoubleWidthLine => Blacklist,
EscCode::DecScreenAlignmentDisplay => Blacklist,
// === BLACKLIST: Application mode key presses (input, not output) ===
EscCode::ApplicationModeArrowUpPress => Blacklist,
EscCode::ApplicationModeArrowDownPress => Blacklist,
EscCode::ApplicationModeArrowRightPress => Blacklist,
EscCode::ApplicationModeArrowLeftPress => Blacklist,
EscCode::ApplicationModeHomePress => Blacklist,
EscCode::ApplicationModeEndPress => Blacklist,
EscCode::F1Press => Blacklist,
EscCode::F2Press => Blacklist,
EscCode::F3Press => Blacklist,
EscCode::F4Press => Blacklist,
},
// === BLACKLIST: Unspecified escape sequences ===
Esc::Unspecified { .. } => Blacklist,
}
}
/// Classify OSC commands - every variant explicitly handled.
fn classify_osc(osc: &termwiz::escape::OperatingSystemCommand) -> Classification {
use Classification::*;
use termwiz::escape::osc::OperatingSystemCommand;
match osc {
// === WHITELIST: Title setting ===
OperatingSystemCommand::SetIconNameAndWindowTitle(_) => Whitelist,
OperatingSystemCommand::SetIconName(_) => Whitelist,
OperatingSystemCommand::SetIconNameSun(_) => Whitelist,
OperatingSystemCommand::SetWindowTitle(_) => Whitelist,
OperatingSystemCommand::SetWindowTitleSun(_) => Whitelist,
// === WHITELIST: Hyperlinks ===
OperatingSystemCommand::SetHyperlink(_) => Whitelist,
// === WHITELIST: Color palette setting ===
OperatingSystemCommand::ChangeColorNumber(_) => Whitelist,
// === WHITELIST: Reset colors ===
OperatingSystemCommand::ResetColors(_) => Whitelist,
// === WHITELIST: Reset dynamic color ===
OperatingSystemCommand::ResetDynamicColor(_) => Whitelist,
// === WHITELIST: Selection setting (not query) ===
OperatingSystemCommand::SetSelection(_, _) => Whitelist,
// === WHITELIST: System notification ===
OperatingSystemCommand::SystemNotification(_) => Whitelist,
// === BLACKLIST: Selection clear ===
OperatingSystemCommand::ClearSelection(_) => Blacklist,
// === BLACKLIST: Selection query ===
OperatingSystemCommand::QuerySelection(_) => Blacklist,
// === MIXED: Dynamic colors (check for query) ===
OperatingSystemCommand::ChangeDynamicColors(_, colors) => {
// Whitelist only if ALL are Color, not Query
if colors
.iter()
.all(|c| matches!(c, termwiz::escape::osc::ColorOrQuery::Color(_)))
{
Whitelist
} else {
Blacklist
}
}
// === MIXED: iTerm proprietary ===
OperatingSystemCommand::ITermProprietary(iterm) => classify_iterm(iterm),
// === WHITELIST: FinalTerm semantic prompts ===
OperatingSystemCommand::FinalTermSemanticPrompt(_) => Whitelist,
// === BLACKLIST: Current directory (pwd) - could leak info ===
OperatingSystemCommand::CurrentWorkingDirectory(_) => Blacklist,
// === BLACKLIST: Rxvt extension ===
OperatingSystemCommand::RxvtExtension(_) => Blacklist,
// === BLACKLIST: ConEmu progress ===
OperatingSystemCommand::ConEmuProgress(_) => Blacklist,
// === BLACKLIST: Unspecified OSC ===
OperatingSystemCommand::Unspecified(_) => Blacklist,
}
}
/// Classify iTerm proprietary OSC commands.
fn classify_iterm(iterm: &termwiz::escape::osc::ITermProprietary) -> Classification {
use Classification::*;
use termwiz::escape::osc::ITermProprietary;
match iterm {
// === WHITELIST: File/image data ===
ITermProprietary::File(_) => Whitelist,
// === WHITELIST: Marks ===
ITermProprietary::SetMark => Whitelist,
// === WHITELIST: User variables ===
ITermProprietary::SetUserVar { .. } => Whitelist,
// === WHITELIST: Badge ===
ITermProprietary::SetBadgeFormat(_) => Whitelist,
// === WHITELIST: Profile ===
ITermProprietary::SetProfile(_) => Whitelist,
// === WHITELIST: Copy to pasteboard ===
ITermProprietary::CopyToClipboard(_) => Whitelist,
// === WHITELIST: Copy string ===
ITermProprietary::Copy(_) => Whitelist,
// === BLACKLIST: Current directory ===
ITermProprietary::CurrentDir(_) => Blacklist,
// === BLACKLIST: Cell size request (triggers response) ===
ITermProprietary::RequestCellSize => Blacklist,
// === BLACKLIST: Cell size response ===
ITermProprietary::ReportCellSize { .. } => Blacklist,
// === BLACKLIST: Report variable (triggers response) ===
ITermProprietary::ReportVariable(_) => Blacklist,
// === BLACKLIST: Unicode version ===
ITermProprietary::UnicodeVersion(_) => Blacklist,
// === BLACKLIST: Stealing focus ===
ITermProprietary::StealFocus => Blacklist,
// === BLACKLIST: Clear scrollback ===
ITermProprietary::ClearScrollback => Blacklist,
// === BLACKLIST: End copy ===
ITermProprietary::EndCopy => Blacklist,
// === BLACKLIST: Highlight cursor line ===
ITermProprietary::HighlightCursorLine(_) => Blacklist,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plain_text_passes() {
let mut filter = HistoryFilter::new();
let output = filter.filter(b"Hello, World!");
assert_eq!(output, b"Hello, World!");
}
#[test]
fn test_sgr_passes() {
let mut filter = HistoryFilter::new();
// Bold red text
let input = b"\x1b[1;31mRed\x1b[0m";
let output = filter.filter(input);
// termwiz re-encodes, so check it contains the key parts
assert!(!output.is_empty());
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("Red"));
}
#[test]
fn test_cursor_movement_passes() {
let mut filter = HistoryFilter::new();
// Move cursor to position 1,1
let input = b"\x1b[1;1H";
let output = filter.filter(input);
assert!(!output.is_empty());
}
#[test]
fn test_focus_tracking_filtered() {
let mut filter = HistoryFilter::new();
// Enable focus reporting (CSI ? 1004 h)
let input = b"\x1b[?1004h";
let output = filter.filter(input);
assert!(output.is_empty(), "Focus tracking should be filtered");
}
#[test]
fn test_mouse_mode_filtered() {
let mut filter = HistoryFilter::new();
// Enable mouse tracking (CSI ? 1000 h)
let input = b"\x1b[?1000h";
let output = filter.filter(input);
assert!(output.is_empty(), "Mouse tracking should be filtered");
}
#[test]
fn test_bracketed_paste_filtered() {
let mut filter = HistoryFilter::new();
// Enable bracketed paste (CSI ? 2004 h)
let input = b"\x1b[?2004h";
let output = filter.filter(input);
assert!(output.is_empty(), "Bracketed paste should be filtered");
}
#[test]
fn test_device_attributes_filtered() {
let mut filter = HistoryFilter::new();
// Primary DA query
let input = b"\x1b[c";
let output = filter.filter(input);
assert!(output.is_empty(), "DA query should be filtered");
}
#[test]
fn test_mixed_content() {
let mut filter = HistoryFilter::new();
// Mix of safe and unsafe
let input = b"Hello\x1b[?1004hWorld\x1b[31mRed\x1b[0m";
let output = filter.filter(input);
let output_str = String::from_utf8_lossy(&output);
assert!(output_str.contains("Hello"));
assert!(output_str.contains("World"));
assert!(output_str.contains("Red"));
// Should not contain the focus tracking sequence
assert!(!output_str.contains("1004"));
}
#[test]
fn test_osc_title_passes() {
let mut filter = HistoryFilter::new();
// Set window title
let input = b"\x1b]0;My Title\x07";
let output = filter.filter(input);
assert!(!output.is_empty());
}
#[test]
fn test_osc_query_filtered() {
let mut filter = HistoryFilter::new();
// Query foreground color
let input = b"\x1b]10;?\x07";
let output = filter.filter(input);
assert!(output.is_empty(), "OSC query should be filtered");
}
}

View File

@@ -1,6 +1,7 @@
pub mod config;
pub mod escape_filter;
pub mod escape_sequences;
pub mod history_filter;
pub mod key_parser;
pub mod line_buffer;
pub mod proxy;

View File

@@ -1,9 +1,9 @@
use crate::escape_filter::TerminalQueryFilter;
use crate::escape_sequences::{
ALT_SCREEN_ENTER, ALT_SCREEN_ENTER_LEGACY, ALT_SCREEN_EXIT, ALT_SCREEN_EXIT_LEGACY,
CLEAR_SCREEN, CURSOR_HOME, INPUT_BUFFER_CAPACITY, OUTPUT_BUFFER_CAPACITY, SYNC_BUFFER_CAPACITY,
SYNC_END, SYNC_START,
};
use crate::history_filter::HistoryFilter;
use crate::line_buffer::LineBuffer;
use anyhow::{Context, Result};
use log::debug;
@@ -21,6 +21,9 @@ use std::os::unix::process::CommandExt;
use std::process::{Child, Command, ExitStatus};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use termwiz::escape::Action;
use termwiz::escape::csi::{CSI, Keyboard};
use termwiz::escape::parser::Parser as TermwizParser;
static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false);
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
@@ -97,7 +100,7 @@ pub struct Proxy {
child: Child,
original_termios: Option<Termios>,
history: LineBuffer,
history_filter: TerminalQueryFilter,
history_filter: HistoryFilter,
vt_parser: vt100::Parser,
vt_prev_screen: Option<vt100::Screen>,
last_output_time: Option<Instant>,
@@ -110,9 +113,8 @@ pub struct Proxy {
in_lookback_mode: bool,
in_alternate_screen: bool,
kitty_mode_supported: bool,
kitty_mode_enabled: bool,
kitty_input_buffer: Vec<u8>,
kitty_output_buffer: Vec<u8>,
kitty_mode_stack: u32,
kitty_output_parser: TermwizParser,
vt_render_pending: bool,
lookback_cache: Vec<u8>,
lookback_input_buffer: Vec<u8>,
@@ -127,70 +129,86 @@ pub struct Proxy {
alt_screen_exit_legacy_finder: memmem::Finder<'static>,
}
const MAX_KITTY_SEQ_LEN: usize = 16;
/// Returns (supported, initial_flags) - if flags > 0, terminal is already in Kitty mode
fn detect_kitty_support() -> (bool, u32) {
use std::io::Write;
use termwiz::escape::csi::Device;
fn is_complete_kitty_output_seq(data: &[u8]) -> bool {
if data.len() < 4 || data[0] != 0x1b || data[1] != b'[' {
return false;
}
if data[2] == b'<' && data[3] == b'u' {
return true;
}
if data[2] == b'>' {
let mut j = 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
}
if j > 3 && j < data.len() && data[j] == b'u' {
return true;
}
}
false
}
// Query sequences:
// CSI ? u - Kitty keyboard protocol query
// CSI c - Primary Device Attributes (all terminals respond)
const KITTY_QUERY: &[u8] = b"\x1b[?u";
const DA_QUERY: &[u8] = b"\x1b[c";
fn is_complete_kitty_input_seq(data: &[u8]) -> bool {
if data.len() < 5 || data[0] != 0x1b || data[1] != b'[' || data[2] != b'?' {
return false;
// Send both queries
let stdout = std::io::stdout();
let mut stdout_lock = stdout.lock();
if stdout_lock.write_all(KITTY_QUERY).is_err() {
return (false, 0);
}
let mut j = 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
if stdout_lock.write_all(DA_QUERY).is_err() {
return (false, 0);
}
j > 3 && j < data.len() && data[j] == b'u'
if stdout_lock.flush().is_err() {
return (false, 0);
}
drop(stdout_lock);
fn kitty_output_split_point(buffer: &[u8]) -> usize {
let last_esc = buffer.iter().rposition(|&b| b == 0x1b);
// Read responses with timeout using termwiz parser
let stdin = std::io::stdin();
let mut parser = TermwizParser::new();
let mut buf = [0u8; 256];
let mut kitty_supported = false;
let mut kitty_flags: u32 = 0;
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(500);
let poll_interval = PollTimeout::from(50u16);
match last_esc {
None => buffer.len(),
Some(pos) if pos + MAX_KITTY_SEQ_LEN <= buffer.len() => buffer.len(),
Some(pos) => {
let tail = &buffer[pos..];
if is_complete_kitty_output_seq(tail) {
buffer.len()
} else {
pos
while start.elapsed() < timeout {
let mut poll_fd = [PollFd::new(stdin.as_fd(), PollFlags::POLLIN)];
match poll(&mut poll_fd, poll_interval) {
Ok(0) => continue,
Ok(_) => {
match read(stdin.as_fd(), &mut buf) {
Ok(0) => continue,
Ok(n) => {
let actions = parser.parse_as_vec(&buf[..n]);
for action in actions {
if let Action::CSI(csi) = action {
match csi {
CSI::Keyboard(Keyboard::ReportKittyState(flags)) => {
kitty_supported = true;
kitty_flags = u32::from(flags.bits());
}
CSI::Device(dev)
if matches!(*dev, Device::DeviceAttributes(_)) =>
{
// DA response means all responses received
debug!(
"Kitty detection complete: supported={} flags={}",
kitty_supported, kitty_flags
);
return (kitty_supported, kitty_flags);
}
_ => {}
}
}
}
}
Err(e) if e == Errno::EAGAIN || e == Errno::EWOULDBLOCK => continue,
Err(_) => break,
}
}
Err(_) => continue,
}
}
fn kitty_input_split_point(buffer: &[u8]) -> usize {
let last_esc = buffer.iter().rposition(|&b| b == 0x1b);
match last_esc {
None => buffer.len(),
Some(pos) if pos + MAX_KITTY_SEQ_LEN <= buffer.len() => buffer.len(),
Some(pos) => {
let tail = &buffer[pos..];
if is_complete_kitty_input_seq(tail) {
buffer.len()
} else {
pos
}
}
}
debug!(
"Kitty detection timed out, supported={} flags={}",
kitty_supported, kitty_flags
);
(kitty_supported, kitty_flags)
}
impl Proxy {
@@ -201,6 +219,11 @@ impl Proxy {
let terminal_guard = TerminalGuard::new()?;
setup_signal_handlers()?;
// Detect Kitty support before spawning child
// If flags > 0, terminal is already in Kitty mode (inherited from parent)
let (kitty_supported, kitty_initial_flags) = detect_kitty_support();
let kitty_initial_stack = if kitty_initial_flags > 0 { 1 } else { 0 };
let slave_fd = pty.slave.as_raw_fd();
let child = unsafe {
@@ -247,7 +270,7 @@ impl Proxy {
Ok(Self {
history,
history_filter: TerminalQueryFilter::new(),
history_filter: HistoryFilter::new(),
config,
pty_master: pty.master,
child,
@@ -263,10 +286,9 @@ impl Proxy {
in_sync_block: false,
in_lookback_mode: false,
in_alternate_screen: false,
kitty_mode_supported: false,
kitty_mode_enabled: false,
kitty_input_buffer: Vec::with_capacity(16),
kitty_output_buffer: Vec::with_capacity(16),
kitty_mode_supported: kitty_supported,
kitty_mode_stack: kitty_initial_stack,
kitty_output_parser: TermwizParser::new(),
vt_render_pending: false,
lookback_cache: Vec::new(),
lookback_input_buffer: Vec::with_capacity(INPUT_BUFFER_CAPACITY),
@@ -378,14 +400,11 @@ impl Proxy {
feed_vt
);
// Track Kitty keyboard protocol state from output
self.update_kitty_mode_from_output(data);
if self.in_alternate_screen {
// Still feed VT and history while in alt screen so they stay in sync
// Feed VT but NOT history while in alt screen
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history
if feed_vt {
self.vt_parser.process(data);
self.push_to_history(data);
}
return self.process_output_alt_screen(data, stdout_fd);
}
@@ -425,7 +444,7 @@ impl Proxy {
self.in_alternate_screen = true;
let seq_len = self.alt_screen_enter_len(&data[pos + alt_pos..]);
// Write alt screen enter directly
write_all(stdout_fd, &data[pos + alt_pos..pos + alt_pos + seq_len])?;
self.write_to_terminal(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);
}
@@ -467,9 +486,9 @@ impl Proxy {
"process_output_alt_screen: ALT_SCREEN_EXIT detected at pos={}",
exit_pos
);
write_all(stdout_fd, &data[..exit_pos])?;
self.write_to_terminal(stdout_fd, &data[..exit_pos])?;
let seq_len = self.alt_screen_exit_len(&data[exit_pos..]);
write_all(stdout_fd, &data[exit_pos..exit_pos + seq_len])?;
self.write_to_terminal(stdout_fd, &data[exit_pos..exit_pos + seq_len])?;
self.in_alternate_screen = false;
// Force full VT render to restore main screen content
@@ -489,7 +508,7 @@ impl Proxy {
}
return Ok(());
}
write_all(stdout_fd, data)
self.write_to_terminal(stdout_fd, data)
}
/// Check for alt screen transitions without re-feeding VT/history
@@ -501,7 +520,7 @@ impl Proxy {
);
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])?;
self.write_to_terminal(stdout_fd, &data[alt_pos..alt_pos + seq_len])?;
return self.process_output_alt_screen(&data[alt_pos + seq_len..], stdout_fd);
}
Ok(())
@@ -545,90 +564,62 @@ impl Proxy {
}
}
fn update_kitty_mode_from_output(&mut self, data: &[u8]) {
self.kitty_output_buffer.extend_from_slice(data);
let split_point = kitty_output_split_point(&self.kitty_output_buffer);
if split_point > 0 {
let to_parse: Vec<u8> = self.kitty_output_buffer.drain(..split_point).collect();
self.parse_kitty_output(&to_parse);
fn kitty_mode_enabled(&self) -> bool {
self.kitty_mode_stack > 0
}
// Safety cap on carryover buffer
if self.kitty_output_buffer.len() > 64 {
let overflow: Vec<u8> = self.kitty_output_buffer.drain(..).collect();
self.parse_kitty_output(&overflow);
}
/// Write data to the terminal and track Kitty keyboard protocol state
fn write_to_terminal<F: AsFd>(&mut self, stdout_fd: &F, data: &[u8]) -> Result<()> {
Self::update_kitty_mode_helper(
&mut self.kitty_output_parser,
&mut self.kitty_mode_stack,
self.kitty_mode_supported,
data,
);
write_all(stdout_fd, data)
}
fn parse_kitty_output(&mut self, data: &[u8]) {
let mut i = 0;
while i < data.len() {
if data[i] == 0x1b && i + 3 < data.len() && data[i + 1] == b'[' {
if data[i + 2] == b'<' && data[i + 3] == b'u' {
debug!("Kitty keyboard protocol disabled");
self.kitty_mode_enabled = false;
i += 4;
continue;
}
if data[i + 2] == b'>' && self.kitty_mode_supported {
let mut j = i + 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
}
if j > i + 3 && j < data.len() && data[j] == b'u' {
debug!("Kitty keyboard protocol enabled");
self.kitty_mode_enabled = true;
i = j + 1;
continue;
fn update_kitty_mode_helper(
parser: &mut TermwizParser,
stack: &mut u32,
supported: bool,
data: &[u8],
) {
let actions = parser.parse_as_vec(data);
for action in actions {
if let Action::CSI(csi) = action {
match csi {
CSI::Keyboard(Keyboard::PushKittyState { flags, .. }) => {
if supported {
*stack = stack.saturating_add(1);
debug!(
"Kitty keyboard protocol push (flags={:?}, stack={})",
flags, stack
);
}
}
}
i += 1;
CSI::Keyboard(Keyboard::SetKittyState { flags, .. }) => {
if supported && !flags.is_empty() && *stack == 0 {
*stack = 1;
debug!(
"Kitty keyboard protocol set (flags={:?}, stack={})",
flags, stack
);
} else if flags.is_empty() && *stack > 0 {
debug!("Kitty keyboard protocol set empty flags (stack={})", stack);
}
}
fn update_kitty_support_from_input(&mut self, data: &[u8]) {
if self.kitty_mode_supported {
return;
CSI::Keyboard(Keyboard::PopKittyState(n)) => {
let prev = *stack;
*stack = stack.saturating_sub(n);
debug!(
"Kitty keyboard protocol pop {} (stack {} -> {})",
n, prev, stack
);
}
self.kitty_input_buffer.extend_from_slice(data);
let split_point = kitty_input_split_point(&self.kitty_input_buffer);
if split_point > 0 {
let to_parse: Vec<u8> = self.kitty_input_buffer.drain(..split_point).collect();
self.parse_kitty_input(&to_parse);
}
// Safety cap on carryover buffer
if self.kitty_input_buffer.len() > 64 {
let overflow: Vec<u8> = self.kitty_input_buffer.drain(..).collect();
self.parse_kitty_input(&overflow);
_ => {}
}
}
fn parse_kitty_input(&mut self, data: &[u8]) {
if self.kitty_mode_supported {
return;
}
let mut i = 0;
while i < data.len() {
if data[i] == 0x1b && i + 4 < data.len() && data[i + 1] == b'[' && data[i + 2] == b'?' {
let mut j = i + 3;
while j < data.len() && data[j].is_ascii_digit() {
j += 1;
}
if j > i + 3 && j < data.len() && data[j] == b'u' {
debug!("Kitty keyboard protocol supported (saw query response)");
self.kitty_mode_supported = true;
return;
}
}
i += 1;
}
}
@@ -735,6 +726,14 @@ impl Proxy {
is_diff,
self.output_buffer.len()
);
// Can't use write_to_terminal here due to borrow checker - can't pass
// &self.output_buffer while also taking &mut self
Self::update_kitty_mode_helper(
&mut self.kitty_output_parser,
&mut self.kitty_mode_stack,
self.kitty_mode_supported,
&self.output_buffer,
);
write_all(stdout_fd, &self.output_buffer)?;
// Store current screen for next diff
@@ -795,8 +794,23 @@ impl Proxy {
self.output_buffer.clear();
self.history.append_all(&mut self.output_buffer);
write_all(stdout_fd, CLEAR_SCREEN)?;
write_all(stdout_fd, CURSOR_HOME)?;
// Debug: write history to file if CLAUDE_CHILL_HISTORY_FILE is set
if let Ok(path) = std::env::var("CLAUDE_CHILL_HISTORY_FILE")
&& let Err(e) = std::fs::write(&path, &self.output_buffer)
{
debug!("Failed to write history file: {}", e);
}
self.write_to_terminal(stdout_fd, CLEAR_SCREEN)?;
self.write_to_terminal(stdout_fd, CURSOR_HOME)?;
// Can't use write_to_terminal here due to borrow checker - can't pass
// &self.output_buffer while also taking &mut self
Self::update_kitty_mode_helper(
&mut self.kitty_output_parser,
&mut self.kitty_mode_stack,
self.kitty_mode_supported,
&self.output_buffer,
);
write_all(stdout_fd, &self.output_buffer)?;
// Force full VT render on next output since terminal now shows history
@@ -807,13 +821,13 @@ impl Proxy {
fn process_input<F: AsFd>(&mut self, data: &[u8], stdout_fd: &F) -> Result<()> {
self.last_stdin_time = Some(Instant::now());
self.update_kitty_support_from_input(data);
debug!("process_input: stdin={:?}", data);
if self.in_alternate_screen {
return write_all(&self.pty_master, data);
}
let lookback_sequence = if self.kitty_mode_enabled {
let lookback_sequence = if self.kitty_mode_enabled() {
self.config.lookback_sequence_kitty.clone()
} else {
self.config.lookback_sequence_legacy.clone()
@@ -849,18 +863,19 @@ impl Proxy {
}
continue;
}
SequenceMatch::Partial => {}
SequenceMatch::Partial => {
// Still might be lookback sequence, don't forward yet
continue;
}
SequenceMatch::None => {
if !lookback_sequence.starts_with(&self.lookback_input_buffer) {
// Not a lookback sequence - forward all buffered bytes
if !self.in_lookback_mode {
write_all(&self.pty_master, &self.lookback_input_buffer)?;
}
self.lookback_input_buffer.clear();
}
}
}
if lookback_action == SequenceMatch::None && !self.in_lookback_mode {
write_all(&self.pty_master, &[byte])?;
}
}
Ok(())
}
@@ -1076,212 +1091,259 @@ fn nix_read<F: AsFd>(fd: &F, buf: &mut [u8]) -> Result<usize, Errno> {
mod tests {
use super::*;
// Tests for is_complete_kitty_output_seq
// Helper to test Kitty tracking using the real update_kitty_mode_helper
struct KittyTracker {
parser: TermwizParser,
mode_supported: bool,
mode_stack: u32,
}
impl KittyTracker {
fn new() -> Self {
Self {
parser: TermwizParser::new(),
mode_supported: false,
mode_stack: 0,
}
}
fn mode_enabled(&self) -> bool {
self.mode_stack > 0
}
fn process_output(&mut self, data: &[u8]) {
// Uses the real production function
Proxy::update_kitty_mode_helper(
&mut self.parser,
&mut self.mode_stack,
self.mode_supported,
data,
);
}
fn process_input(&mut self, data: &[u8]) {
// Kitty support detection from query response (CSI ? flags u)
// This is done separately in detect_kitty_support() at startup
if self.mode_supported {
return;
}
let actions = self.parser.parse_as_vec(data);
for action in actions {
if let Action::CSI(CSI::Keyboard(Keyboard::ReportKittyState(_))) = action {
self.mode_supported = true;
return;
}
}
}
}
// Tests for Kitty keyboard protocol tracking
#[test]
fn test_output_seq_disable_complete() {
assert!(is_complete_kitty_output_seq(b"\x1b[<u"));
fn test_kitty_initially_disabled() {
let tracker = KittyTracker::new();
assert!(!tracker.mode_enabled());
assert!(!tracker.mode_supported);
}
#[test]
fn test_output_seq_disable_with_trailing() {
assert!(is_complete_kitty_output_seq(b"\x1b[<uhello"));
fn test_kitty_support_detected_from_query_response() {
let mut tracker = KittyTracker::new();
// Terminal responds to query with CSI ? flags u
tracker.process_input(b"\x1b[?1u");
assert!(tracker.mode_supported);
}
#[test]
fn test_output_seq_enable_complete() {
assert!(is_complete_kitty_output_seq(b"\x1b[>1u"));
fn test_kitty_push_increments_stack() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// CSI > 1 u = push with flags
tracker.process_output(b"\x1b[>1u");
assert_eq!(tracker.mode_stack, 1);
assert!(tracker.mode_enabled());
}
#[test]
fn test_output_seq_enable_multi_digit() {
assert!(is_complete_kitty_output_seq(b"\x1b[>31u"));
fn test_kitty_push_requires_support() {
let mut tracker = KittyTracker::new();
// Push without support detection - should be ignored
tracker.process_output(b"\x1b[>1u");
assert_eq!(tracker.mode_stack, 0);
assert!(!tracker.mode_enabled());
}
#[test]
fn test_output_seq_enable_with_trailing() {
assert!(is_complete_kitty_output_seq(b"\x1b[>1umore"));
fn test_kitty_pop_decrements_stack() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[<u"); // pop 1
assert_eq!(tracker.mode_stack, 0);
assert!(!tracker.mode_enabled());
}
#[test]
fn test_output_seq_incomplete_esc_only() {
assert!(!is_complete_kitty_output_seq(b"\x1b"));
fn test_kitty_pop_with_count() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[>1u"); // push
assert_eq!(tracker.mode_stack, 3);
tracker.process_output(b"\x1b[<2u"); // pop 2
assert_eq!(tracker.mode_stack, 1);
assert!(tracker.mode_enabled());
}
#[test]
fn test_output_seq_incomplete_esc_bracket() {
assert!(!is_complete_kitty_output_seq(b"\x1b["));
fn test_kitty_pop_saturates_at_zero() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
tracker.process_output(b"\x1b[>1u"); // push
tracker.process_output(b"\x1b[<5u"); // pop 5 (more than we have)
assert_eq!(tracker.mode_stack, 0);
assert!(!tracker.mode_enabled());
}
#[test]
fn test_output_seq_incomplete_disable() {
assert!(!is_complete_kitty_output_seq(b"\x1b[<"));
fn test_kitty_split_sequence_across_buffers() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// Feed the sequence in parts
tracker.process_output(b"\x1b[>");
tracker.process_output(b"1u");
assert_eq!(tracker.mode_stack, 1);
}
#[test]
fn test_output_seq_incomplete_enable_no_digit() {
assert!(!is_complete_kitty_output_seq(b"\x1b[>"));
fn test_kitty_multiple_sequences_in_one_buffer() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// Push twice, pop once, all in one buffer
tracker.process_output(b"\x1b[>1u\x1b[>1u\x1b[<u");
assert_eq!(tracker.mode_stack, 1);
}
#[test]
fn test_output_seq_incomplete_enable_digit_no_u() {
assert!(!is_complete_kitty_output_seq(b"\x1b[>1"));
fn test_kitty_mixed_with_other_sequences() {
let mut tracker = KittyTracker::new();
tracker.mode_supported = true;
// Kitty push mixed with cursor moves and SGR
tracker.process_output(b"\x1b[H\x1b[>1u\x1b[31m\x1b[2J");
assert_eq!(tracker.mode_stack, 1);
}
#[test]
fn test_output_seq_not_kitty() {
assert!(!is_complete_kitty_output_seq(b"\x1b[H"));
assert!(!is_complete_kitty_output_seq(b"\x1b[2J"));
fn test_kitty_typical_session_flow() {
let mut tracker = KittyTracker::new();
// 1. Terminal responds to query
tracker.process_input(b"\x1b[?1u");
assert!(tracker.mode_supported);
assert!(!tracker.mode_enabled());
// 2. App pushes keyboard mode
tracker.process_output(b"\x1b[>1u");
assert!(tracker.mode_enabled());
// 3. App pops keyboard mode on exit
tracker.process_output(b"\x1b[<u");
assert!(!tracker.mode_enabled());
}
// Tests for sequence matching (used for lookback key detection)
fn check_sequence(buffer: &[u8], byte: u8, sequence: &[u8]) -> SequenceMatch {
let mut buf = buffer.to_vec();
buf.push(byte);
if buf.len() > sequence.len() {
let excess = buf.len() - sequence.len();
buf.drain(..excess);
}
if buf.as_slice() == sequence {
SequenceMatch::Complete
} else if sequence.starts_with(&buf) {
SequenceMatch::Partial
} else {
SequenceMatch::None
}
}
#[test]
fn test_output_seq_empty() {
assert!(!is_complete_kitty_output_seq(b""));
fn test_sequence_match_complete_single_byte() {
// Single byte sequence (legacy Ctrl+6 = 0x1E)
let sequence = &[0x1E];
assert_eq!(check_sequence(&[], 0x1E, sequence), SequenceMatch::Complete);
}
#[test]
fn test_output_seq_no_esc() {
assert!(!is_complete_kitty_output_seq(b"hello"));
fn test_sequence_match_complete_multi_byte() {
// Multi-byte Kitty sequence: ESC [ 5 4 ; 5 u
let sequence = b"\x1b[54;5u";
let mut buffer = Vec::new();
for &byte in &sequence[..sequence.len() - 1] {
let result = check_sequence(&buffer, byte, sequence);
assert_eq!(result, SequenceMatch::Partial);
buffer.push(byte);
if buffer.len() > sequence.len() {
buffer.drain(..buffer.len() - sequence.len());
}
// Tests for is_complete_kitty_input_seq
#[test]
fn test_input_seq_query_response_complete() {
assert!(is_complete_kitty_input_seq(b"\x1b[?1u"));
}
// Final byte completes the sequence
assert_eq!(
check_sequence(&buffer, sequence[sequence.len() - 1], sequence),
SequenceMatch::Complete
);
}
#[test]
fn test_input_seq_query_response_multi_digit() {
assert!(is_complete_kitty_input_seq(b"\x1b[?31u"));
fn test_sequence_match_partial() {
let sequence = b"\x1b[54;5u";
assert_eq!(check_sequence(&[], 0x1b, sequence), SequenceMatch::Partial);
assert_eq!(
check_sequence(&[0x1b], b'[', sequence),
SequenceMatch::Partial
);
assert_eq!(
check_sequence(&[0x1b, b'['], b'5', sequence),
SequenceMatch::Partial
);
}
#[test]
fn test_input_seq_query_response_with_trailing() {
assert!(is_complete_kitty_input_seq(b"\x1b[?1umore"));
fn test_sequence_match_none_wrong_byte() {
let sequence = b"\x1b[54;5u";
// Start with wrong byte
assert_eq!(check_sequence(&[], b'a', sequence), SequenceMatch::None);
// Wrong byte after partial match
assert_eq!(check_sequence(&[0x1b], b'O', sequence), SequenceMatch::None);
}
#[test]
fn test_input_seq_incomplete_esc_only() {
assert!(!is_complete_kitty_input_seq(b"\x1b"));
fn test_sequence_match_buffer_rolling() {
// Test that the rolling buffer properly handles the case where
// random bytes precede the actual sequence. The buffer keeps
// only the last N bytes where N = sequence.len()
let sequence = b"\x1b[54;5u"; // 7 bytes
// User types random chars - no match
assert_eq!(check_sequence(&[], b'a', sequence), SequenceMatch::None);
assert_eq!(check_sequence(b"a", b'b', sequence), SequenceMatch::None);
// Buffer [a, b, ESC] doesn't start sequence (sequence starts with ESC)
assert_eq!(check_sequence(b"ab", 0x1b, sequence), SequenceMatch::None);
// After more typing, old bytes get trimmed from buffer
// When buffer finally contains just ESC at the right position, it matches
// But with rolling buffer, we need the EXACT prefix
// Fresh start: ESC alone is a partial match
assert_eq!(check_sequence(&[], 0x1b, sequence), SequenceMatch::Partial);
}
#[test]
fn test_input_seq_incomplete_esc_bracket() {
assert!(!is_complete_kitty_input_seq(b"\x1b["));
}
#[test]
fn test_input_seq_incomplete_esc_bracket_question() {
assert!(!is_complete_kitty_input_seq(b"\x1b[?"));
}
#[test]
fn test_input_seq_incomplete_no_u() {
assert!(!is_complete_kitty_input_seq(b"\x1b[?1"));
}
#[test]
fn test_input_seq_not_kitty() {
assert!(!is_complete_kitty_input_seq(b"\x1b[H"));
assert!(!is_complete_kitty_input_seq(b"\x1b[?25h"));
}
#[test]
fn test_input_seq_empty() {
assert!(!is_complete_kitty_input_seq(b""));
}
// Tests for kitty_output_split_point
#[test]
fn test_output_split_no_esc() {
assert_eq!(kitty_output_split_point(b"hello world"), 11);
}
#[test]
fn test_output_split_empty() {
assert_eq!(kitty_output_split_point(b""), 0);
}
#[test]
fn test_output_split_complete_disable() {
// Complete sequence - split at end (process all)
assert_eq!(kitty_output_split_point(b"\x1b[<u"), 4);
}
#[test]
fn test_output_split_complete_enable() {
assert_eq!(kitty_output_split_point(b"\x1b[>1u"), 5);
}
#[test]
fn test_output_split_incomplete_esc_only() {
// Just ESC - incomplete, split at 0 (keep all)
assert_eq!(kitty_output_split_point(b"\x1b"), 0);
}
#[test]
fn test_output_split_incomplete_enable() {
// Incomplete enable - split at ESC position
assert_eq!(kitty_output_split_point(b"\x1b[>"), 0);
assert_eq!(kitty_output_split_point(b"\x1b[>1"), 0);
}
#[test]
fn test_output_split_data_then_incomplete() {
// Data followed by incomplete - split before ESC
let data = b"hello\x1b[>";
assert_eq!(kitty_output_split_point(data), 5);
}
#[test]
fn test_output_split_complete_then_incomplete() {
// Complete disable followed by incomplete enable
let data = b"\x1b[<u\x1b[>";
assert_eq!(kitty_output_split_point(data), 4);
}
#[test]
fn test_output_split_esc_far_from_end() {
// ESC more than 16 bytes from end - process all
let mut data = b"\x1b[<u".to_vec();
data.extend_from_slice(b"this is padding longer than 16 bytes!");
assert_eq!(kitty_output_split_point(&data), data.len());
}
// Tests for kitty_input_split_point
#[test]
fn test_input_split_no_esc() {
assert_eq!(kitty_input_split_point(b"hello world"), 11);
}
#[test]
fn test_input_split_empty() {
assert_eq!(kitty_input_split_point(b""), 0);
}
#[test]
fn test_input_split_complete_query_response() {
assert_eq!(kitty_input_split_point(b"\x1b[?1u"), 5);
}
#[test]
fn test_input_split_incomplete() {
assert_eq!(kitty_input_split_point(b"\x1b[?"), 0);
assert_eq!(kitty_input_split_point(b"\x1b[?1"), 0);
}
#[test]
fn test_input_split_data_then_incomplete() {
let data = b"hello\x1b[?";
assert_eq!(kitty_input_split_point(data), 5);
}
#[test]
fn test_input_split_complete_then_incomplete() {
let data = b"\x1b[?1u\x1b[?";
assert_eq!(kitty_input_split_point(data), 5);
fn test_sequence_match_interleaved_typing() {
// User types "ab" then the lookback sequence
let sequence = &[0x1E];
assert_eq!(check_sequence(&[], b'a', sequence), SequenceMatch::None);
assert_eq!(check_sequence(b"a", b'b', sequence), SequenceMatch::None);
assert_eq!(
check_sequence(b"ab", 0x1E, sequence),
SequenceMatch::Complete
);
}
}