diff --git a/Cargo.lock b/Cargo.lock index 742600d..d2a7bc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 1f1ea40..916c086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,6 @@ members = [ ] [workspace.package] -version = "0.1.3" +version = "0.1.4" edition = "2024" authors = ["David Beesley"] diff --git a/README.md b/README.md index 36ce0eb..7256e0d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/crates/claude-chill/Cargo.toml b/crates/claude-chill/Cargo.toml index 0519d36..376cf37 100644 --- a/crates/claude-chill/Cargo.toml +++ b/crates/claude-chill/Cargo.toml @@ -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" diff --git a/crates/claude-chill/src/history_filter.rs b/crates/claude-chill/src/history_filter.rs new file mode 100644 index 0000000..078e625 --- /dev/null +++ b/crates/claude-chill/src/history_filter.rs @@ -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 { + 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"); + } +} diff --git a/crates/claude-chill/src/lib.rs b/crates/claude-chill/src/lib.rs index 4db9869..72f918b 100644 --- a/crates/claude-chill/src/lib.rs +++ b/crates/claude-chill/src/lib.rs @@ -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; diff --git a/crates/claude-chill/src/proxy.rs b/crates/claude-chill/src/proxy.rs index 6bb3b03..857dba1 100644 --- a/crates/claude-chill/src/proxy.rs +++ b/crates/claude-chill/src/proxy.rs @@ -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, history: LineBuffer, - history_filter: TerminalQueryFilter, + history_filter: HistoryFilter, vt_parser: vt100::Parser, vt_prev_screen: Option, last_output_time: Option, @@ -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, - kitty_output_buffer: Vec, + kitty_mode_stack: u32, + kitty_output_parser: TermwizParser, vt_render_pending: bool, lookback_cache: Vec, lookback_input_buffer: Vec, @@ -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 = self.kitty_output_buffer.drain(..split_point).collect(); - self.parse_kitty_output(&to_parse); - } - - // Safety cap on carryover buffer - if self.kitty_output_buffer.len() > 64 { - let overflow: Vec = self.kitty_output_buffer.drain(..).collect(); - self.parse_kitty_output(&overflow); - } + fn kitty_mode_enabled(&self) -> bool { + self.kitty_mode_stack > 0 } - 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; + /// Write data to the terminal and track Kitty keyboard protocol state + fn write_to_terminal(&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 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 + ); + } } - 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; + 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); + } } + CSI::Keyboard(Keyboard::PopKittyState(n)) => { + let prev = *stack; + *stack = stack.saturating_sub(n); + debug!( + "Kitty keyboard protocol pop {} (stack {} -> {})", + n, prev, stack + ); + } + _ => {} } } - i += 1; - } - } - - fn update_kitty_support_from_input(&mut self, data: &[u8]) { - if self.kitty_mode_supported { - return; - } - - 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 = 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 = 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(&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,16 +863,17 @@ impl Proxy { } continue; } - SequenceMatch::Partial => {} - SequenceMatch::None => { - if !lookback_sequence.starts_with(&self.lookback_input_buffer) { - self.lookback_input_buffer.clear(); - } + SequenceMatch::Partial => { + // Still might be lookback sequence, don't forward yet + continue; + } + SequenceMatch::None => { + // 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(fd: &F, buf: &mut [u8]) -> Result { 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[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[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[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[ 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")); - } - - // Tests for is_complete_kitty_input_seq - - #[test] - fn test_input_seq_query_response_complete() { - assert!(is_complete_kitty_input_seq(b"\x1b[?1u")); + 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()); + } + } + // 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[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["; - 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[