diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f32e70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..505c9bc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4193 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_encoder" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bfb5cf22391514bc73a3905267b01ee10c767b256719ae69267661564aff7c" +dependencies = [ + "chardetng", + "encoding_rs", + "memchr", + "phf 0.11.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[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 = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chardetng" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea" +dependencies = [ + "cfg-if", + "encoding_rs", + "memchr", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[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 0.11.3", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros 0.6.1", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros 0.7.0", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.118", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.118", +] + +[[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 = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set 0.8.0", + "cssparser 0.37.0", + "foldhash", + "html5ever", + "nom 8.0.0", + "precomputed-hash", + "selectors 0.38.0", + "tendril", +] + +[[package]] +name = "dom_smoothie" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8b9b294aabb8010b37c49a07d6f82175152f4927855d534979a38737721875" +dependencies = [ + "dom_query", + "flagset", + "foldhash", + "gjson", + "html-escape", + "once_cell", + "phf 0.13.1", + "tendril", + "thiserror 2.0.18", + "unicode-segmentation", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +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 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "fast_html2md" +version = "0.0.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88587bc3328375d9866f0f71ffcfe5768fe0df79dde79c79216239711868f77f" +dependencies = [ + "auto_encoder", + "futures-util", + "lazy_static", + "lol_html", + "percent-encoding", + "regex", + "url", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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 = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gjson" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43503cc176394dd30a6525f5f36e838339b8b5619be33ed9a7783841580a97b6" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +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 = "html-escape" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e" + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lol_html" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cssparser 0.36.0", + "encoding_rs", + "foldhash", + "hashbrown 0.17.1", + "memchr", + "mime", + "precomputed-hash", + "selectors 0.37.0", + "thiserror 2.0.18", +] + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[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 = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.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 = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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.118", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +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 = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap", + "nix 0.31.3", + "tokio", + "tracing", + "windows", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.0", + "memchr", + "unicase", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +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 = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.0", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmcp" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +dependencies = [ + "async-trait", + "chrono", + "futures", + "http", + "pastey", + "pin-project-lite", + "process-wrap", + "reqwest 0.13.4", + "rmcp-macros", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.118", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser 0.37.0", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors 0.38.0", + "tendril", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9" +dependencies = [ + "bitflags 2.13.0", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.0", + "cssparser 0.37.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.9.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd24347956e682cf958c95e82deb9914cad4010d3efc035d579f81f4c426038c" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sse-stream" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex 0.16.2", + "flate2", + "fnv", + "once_cell", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[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.13.0", + "fancy-regex 0.11.0", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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.118", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + +[[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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zesdex" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "crossterm 0.28.1", + "dirs", + "dom_smoothie", + "fast_html2md", + "futures-util", + "globset", + "ignore", + "include_dir", + "infer", + "libc", + "percent-encoding", + "pulldown-cmark", + "ratatui", + "regex", + "reqwest 0.12.28", + "rmcp", + "rusqlite", + "scraper", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2", + "syntect", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ed63a6c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "zesdex" +version = "0.1.0" +edition = "2021" +authors = ["asepharyana "] + +[dependencies] +ratatui = "0.30.2" +crossterm = "0.28" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util", "signal"] } +reqwest = { version = "0.12", features = ["json", "stream", "blocking", "native-tls-vendored"] } +dom_smoothie = "0.18.0" +fast_html2md = "0.0.62" +scraper = "0.27.0" +url = "2" +percent-encoding = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml_ng = "0.9" +anyhow = "1" +include_dir = "0.7" +uuid = { version = "1", features = ["v4", "v5"] } +dirs = "5" +futures-util = "0.3" +pulldown-cmark = { version = "0.13", default-features = false } +syntect = { version = "5", default-features = false, features = ["default-fancy"] } +rusqlite = { version = "0.32", features = ["bundled"] } +ignore = "0.4" +regex = "1" +globset = "0.4" +infer = "0.16" +base64 = "0.22" +sha2 = "0.10" +libc = "0.2" +rmcp = { version = "1.8", default-features = false, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest", "macros"] } +chrono = { version = "0.4", features = ["serde"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[[bin]] +name = "zesdex" +path = "src/main.rs" diff --git a/src/app/bgbash/control.rs b/src/app/bgbash/control.rs new file mode 100644 index 0000000..f2866a5 --- /dev/null +++ b/src/app/bgbash/control.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::sync::OnceLock; + +use super::job::BashJob; + +pub(crate) fn bash_jobs_map() -> &'static Mutex> { + static JOBS: OnceLock>> = OnceLock::new(); + JOBS.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn bash_output(id: &str) -> Option> { + let mut map = bash_jobs_map().lock().ok()?; + let job = map.get_mut(id)?; + let mut lines = Vec::new(); + while let Some(line) = job.try_read_line() { + lines.push(line); + } + if lines.is_empty() { None } else { Some(lines) } +} + +pub fn bash_kill(id: &str) -> anyhow::Result<()> { + let mut map = bash_jobs_map().lock().map_err(|e| anyhow::anyhow!("lock error: {}", e))?; + let job = map.remove(id); + if job.is_some() { + Ok(()) + } else { + anyhow::bail!("bash job '{}' not found", id) + } +} + +pub fn register_bash_job(job: BashJob) -> String { + let id = job.id.clone(); + if let Ok(mut map) = bash_jobs_map().lock() { + map.insert(id.clone(), job); + } + id +} diff --git a/src/app/bgbash/job.rs b/src/app/bgbash/job.rs new file mode 100644 index 0000000..6f19af5 --- /dev/null +++ b/src/app/bgbash/job.rs @@ -0,0 +1,75 @@ +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::io::BufRead; + +pub struct BashJob { + pub id: String, + pub command: String, + pub started_at: i64, + pub output_rx: mpsc::Receiver, + pub exit_code: Option, + pub handle: Option>, +} + +pub fn spawn_bash_job(command: String) -> BashJob { + let id = uuid::Uuid::new_v4().to_string(); + let started_at = chrono::Utc::now().timestamp_millis(); + let (output_tx, output_rx) = mpsc::channel::(); + let cmd = command.clone(); + + let handle = thread::spawn(move || { + let child = Command::new("sh") + .arg("-c") + .arg(&cmd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + match child { + Ok(mut child) => { + if let Some(stdout) = child.stdout.take() { + let reader = std::io::BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + let _ = output_tx.send(line); + } + } + let status = child.wait(); + let code = status.ok().and_then(|s| s.code()); + let _ = output_tx.send(format!("__exit:{}", code.unwrap_or(-1))); + } + Err(e) => { + let _ = output_tx.send(format!("__error:{}", e)); + let _ = output_tx.send("__exit:-1".to_string()); + } + } + }); + + BashJob { + id, + command, + started_at, + output_rx, + exit_code: None, + handle: Some(handle), + } +} + +impl BashJob { + pub fn try_read_line(&mut self) -> Option { + match self.output_rx.try_recv() { + Ok(line) => { + if line.starts_with("__exit:") { + self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok()); + None + } else { + Some(line) + } + } + Err(_) => None, + } + } + + pub fn is_running(&self) -> bool { + self.exit_code.is_none() + } +} diff --git a/src/app/bgbash/mod.rs b/src/app/bgbash/mod.rs new file mode 100644 index 0000000..c29f168 --- /dev/null +++ b/src/app/bgbash/mod.rs @@ -0,0 +1,2 @@ +pub mod control; +pub mod job; diff --git a/src/app/catastrophic.rs b/src/app/catastrophic.rs new file mode 100644 index 0000000..99635a9 --- /dev/null +++ b/src/app/catastrophic.rs @@ -0,0 +1,152 @@ +use std::path::Path; + +pub struct CatastrophicGuard; + +impl CatastrophicGuard { + pub fn check_git_operation(cmd: &str) -> Result<(), String> { + let patterns = [ + "force-push", + "reset --hard", + "clean -f", + "clean -d", + "clean -x", + "branch -D", + "branch --delete --force", + "checkout --force", + "switch -f", + "restore --force", + "stash drop", + "stash clear", + "tag -d", + "tag --delete", + "update-ref -d", + "filter-branch", + "gc --prune", + "gc --aggressive", + "push --delete", + "push --force", + "push origin :", + "push +refs", + ]; + let cmd_lower = cmd.to_lowercase(); + for pattern in patterns { + if cmd_lower.contains(pattern) { + return Err(format!("catastrophic git operation blocked: '{}'", pattern)); + } + } + Ok(()) + } + + pub fn check_shell_command(cmd: &str) -> Result<(), String> { + let dangerous = [ + ":(){ :|:& };:", + "> /dev/sda", + "dd if=", + "mkfs.", + "format ", + "fdisk", + "parted", + "mkswap", + "swapoff", + "shutdown", + "reboot", + "poweroff", + "init 0", + "init 6", + "halt", + "> /dev/mem", + "> /dev/kmem", + "chmod 000", + "chown -R 0:0", + ]; + let cmd_lower = cmd.to_lowercase(); + for pattern in dangerous { + if cmd_lower.contains(pattern) { + return Err(format!("catastrophic shell command blocked: '{}'", pattern)); + } + } + Ok(()) + } + + pub fn check_delete_path(path: &Path, _workspace_roots: &[&Path]) -> Result<(), String> { + let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if canon == *"/" + || canon == *"/home" + || canon == *"/root" + { + return Err("catastrophic delete blocked: system directory".to_string()); + } + let in_workspace = _workspace_roots.iter().any(|w| { + let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf()); + canon.starts_with(&wc) + }); + if !in_workspace { + return Err("catastrophic delete blocked: outside all workspace roots".to_string()); + } + Ok(()) + } + + pub fn check_credential_pattern(cmd: &str) -> Result<(), String> { + let patterns = [ + "cat ~/.ssh", + "cat /home/", + ".ssh/id_rsa", + ".ssh/id_ed25519", + ".ssh/authorized_keys", + ".git-credentials", + ".netrc", + "aws/credentials", + "gcloud/credentials", + ".config/gcloud", + ".config/gh", + "token=", + "secret=", + "api_key=", + "api-key=", + "password=", + ]; + let cmd_lower = cmd.to_lowercase(); + for pattern in patterns { + if cmd_lower.contains(pattern) { + return Err(format!("credential read blocked: '{}'", pattern)); + } + } + Ok(()) + } + + pub fn check_download_path(path: &Path) -> Result<(), String> { + let name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + let sensitive = [ + "id_rsa", + "id_ed25519", + "authorized_keys", + "known_hosts", + ".netrc", + ".git-credentials", + "credentials.json", + "service-account", + "secret", + "key.pem", + "key.p8", + "id_ecdsa", + "id_dsa", + "config", + ]; + let name_lower = name.to_lowercase(); + for s in &sensitive { + if name_lower.contains(s) { + return Err(format!("sensitive download blocked: '{}'", s)); + } + } + Ok(()) + } + + pub fn check_all(cmd: &str, _workspace_roots: &[&Path]) -> Result<(), String> { + Self::check_shell_command(cmd)?; + Self::check_git_operation(cmd)?; + Self::check_credential_pattern(cmd)?; + Ok(()) + } +} diff --git a/src/app/harness.rs b/src/app/harness.rs new file mode 100644 index 0000000..db85623 --- /dev/null +++ b/src/app/harness.rs @@ -0,0 +1,66 @@ +#[derive(Debug, Clone, PartialEq)] +pub enum Verdict { + Allow, + Block(String), + Escalate, +} + +impl Verdict { + pub fn is_allowed(&self) -> bool { + matches!(self, Verdict::Allow) + } +} + +pub struct Harness; + +impl Harness { + pub fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict { + if mode.auto_approve() { + return Verdict::Allow; + } + Verdict::Allow + } +} + +pub fn parse_verdict(text: &str) -> Option { + let trimmed = text.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + if let Some(verdict) = v.get("verdict").and_then(|v| v.as_str()) { + return match verdict.to_lowercase().as_str() { + "allow" => Some(Verdict::Allow), + "block" => Some(Verdict::Block( + v.get("reason").and_then(|r| r.as_str()).unwrap_or("blocked").to_string() + )), + "escalate" => Some(Verdict::Escalate), + _ => None, + }; + } + } + for line in trimmed.lines() { + let l = line.trim().to_lowercase(); + if l.starts_with("verdict: allow") { + return Some(Verdict::Allow); + } + if l.starts_with("verdict: block") { + let reason = line.split_once(':').map(|x| x.1).unwrap_or("blocked").trim().to_string(); + return Some(Verdict::Block(reason)); + } + } + if trimmed.to_lowercase().contains("allow") { + return Some(Verdict::Allow); + } + if trimmed.to_lowercase().contains("block") { + return Some(Verdict::Block("blocked by classifier".to_string())); + } + None +} + +pub fn classify(_cmd: &str, mode: &super::state::types::AgentMode) -> Verdict { + Harness::classify(_cmd, mode) +} + +impl Default for Harness { + fn default() -> Self { + Harness + } +} diff --git a/src/app/mcp/manager.rs b/src/app/mcp/manager.rs new file mode 100644 index 0000000..ef82aa9 --- /dev/null +++ b/src/app/mcp/manager.rs @@ -0,0 +1,78 @@ +use serde_json::Value; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum McpTransport { + Stdio { + command: String, + args: Vec, + }, + StreamableHttp { + url: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpToolInfo { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServer { + pub name: String, + pub transport: McpTransport, + pub tools: Vec, +} + +impl McpServer { + pub fn new(name: String, transport: McpTransport) -> Self { + McpServer { + name, + transport, + tools: Vec::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct McpManager { + pub servers: Vec, + pub running: bool, +} + +impl McpManager { + pub fn new() -> Self { + McpManager { + servers: Vec::new(), + running: false, + } + } + + pub fn add_server(&mut self, server: McpServer) { + self.servers.push(server); + } + + pub fn remove_server(&mut self, name: &str) { + self.servers.retain(|s| s.name != name); + } + + pub fn get_server(&self, name: &str) -> Option<&McpServer> { + self.servers.iter().find(|s| s.name == name) + } + + pub fn all_tools(&self) -> Vec<&McpToolInfo> { + self.servers.iter().flat_map(|s| s.tools.iter()).collect() + } + + pub fn start_all(&mut self) -> anyhow::Result<()> { + self.running = true; + Ok(()) + } + + pub fn stop_all(&mut self) -> anyhow::Result<()> { + self.running = false; + Ok(()) + } +} diff --git a/src/app/mcp/mod.rs b/src/app/mcp/mod.rs new file mode 100644 index 0000000..ff8de9e --- /dev/null +++ b/src/app/mcp/mod.rs @@ -0,0 +1 @@ +pub mod manager; diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..c7f55b5 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,11 @@ +pub mod catastrophic; +pub mod harness; +pub mod mode; +pub mod runtime; +pub mod state; +pub mod workflow; +pub mod subagent; +pub mod review; +pub mod bgbash; +pub mod mcp; +pub mod sec; diff --git a/src/app/mode/agents/mod.rs b/src/app/mode/agents/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/bash.rs b/src/app/mode/bash.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/editor.rs b/src/app/mode/editor.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/effort.rs b/src/app/mode/effort.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/help.rs b/src/app/mode/help.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/key_input.rs b/src/app/mode/key_input.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/loading.rs b/src/app/mode/loading.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/mcp.rs b/src/app/mode/mcp.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/mod.rs b/src/app/mode/mod.rs new file mode 100644 index 0000000..42c088d --- /dev/null +++ b/src/app/mode/mod.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +pub mod agents; +pub mod bash; +pub mod editor; +pub mod effort; +pub mod help; +pub mod key_input; +pub mod loading; +pub mod mcp; +pub mod onboard; +pub mod onboard_provider; +pub mod picker; +pub mod quit_confirm; +pub mod rewind; +pub mod security; +pub mod session_hub; +pub mod settings; +pub mod todo; +pub mod workflow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModeKind { + Chat, + Agents, + Bash, + Workflow, + Help, + Settings, + SessionHub, + QuitConfirm, + Onboard, + OnboardProvider, + Picker, + KeyInput, + Editor, + Effort, + Mcp, + Security, + Todo, + Rewind, + Loading, +} + +impl ModeKind { + pub fn name(&self) -> &'static str { + match self { + ModeKind::Chat => "Chat", + ModeKind::Agents => "Agents", + ModeKind::Bash => "Bash", + ModeKind::Workflow => "Workflow", + ModeKind::Help => "Help", + ModeKind::Settings => "Settings", + ModeKind::SessionHub => "SessionHub", + ModeKind::QuitConfirm => "QuitConfirm", + ModeKind::Onboard => "Onboard", + ModeKind::OnboardProvider => "OnboardProvider", + ModeKind::Picker => "Picker", + ModeKind::KeyInput => "KeyInput", + ModeKind::Editor => "Editor", + ModeKind::Effort => "Effort", + ModeKind::Mcp => "MCP", + ModeKind::Security => "Security", + ModeKind::Todo => "Todo", + ModeKind::Rewind => "Rewind", + ModeKind::Loading => "Loading", + } + } + + pub fn is_overlay(self) -> bool { + !matches!(self, ModeKind::Chat | ModeKind::Agents | ModeKind::Bash | ModeKind::Workflow) + } +} diff --git a/src/app/mode/onboard.rs b/src/app/mode/onboard.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/onboard_provider.rs b/src/app/mode/onboard_provider.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/picker.rs b/src/app/mode/picker.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/quit_confirm.rs b/src/app/mode/quit_confirm.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/rewind.rs b/src/app/mode/rewind.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/security.rs b/src/app/mode/security.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/session_hub.rs b/src/app/mode/session_hub.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/settings.rs b/src/app/mode/settings.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/todo.rs b/src/app/mode/todo.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/mode/workflow.rs b/src/app/mode/workflow.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs new file mode 100644 index 0000000..0e560cf --- /dev/null +++ b/src/app/review/mod.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use crate::app::state::rest::AppStateRest; +use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Confidence { + Human, + Verified, + Unverified, + Auto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum LessonLifecycle { + New, + Active, + Stale, + Contradicted, + Superseded, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum LessonScope { + Project, + Global, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Provenance { + pub session_turn: String, + pub session_id: String, + pub reviewer: Origin, +} + +#[derive(Debug, Clone)] +pub struct Lesson { + pub name: String, + pub content: String, + pub confidence: Confidence, + pub outcome: Option, + pub lifecycle: LessonLifecycle, + pub scope: LessonScope, + pub contradiction_with: Option, + pub provenance: Provenance, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ViolationEscalation { + None, + Warning, + Escalate, + Block, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ShadowStatus { + Trial, + Graduated, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShadowCheck { + pub pattern: String, + pub trial_window: u32, + pub trial_count: u32, + pub trial_passed: u32, + pub status: ShadowStatus, +} + +pub struct ReviewSystem { + pub pending: bool, + pub queue_capacity: usize, + pub repeated_violations: HashMap, + pub shadow_violations: Vec, + pub violation_window: u32, +} + +impl ReviewSystem { + pub fn new() -> Self { + ReviewSystem { + pending: false, + queue_capacity: 1, + repeated_violations: HashMap::new(), + shadow_violations: Vec::new(), + violation_window: 10, + } + } + + pub fn reset(&mut self) { + self.pending = false; + } + + pub fn check_escalation(&self, pattern: &str) -> ViolationEscalation { + let count = self.repeated_violations.get(pattern).copied().unwrap_or(0); + match count { + 0 | 1 => ViolationEscalation::None, + 2 => ViolationEscalation::Warning, + 3 | 4 => ViolationEscalation::Escalate, + _ => ViolationEscalation::Block, + } + } + + pub fn increment_violation(&mut self, pattern: &str) -> ViolationEscalation { + let entry = self.repeated_violations.entry(pattern.to_string()).or_insert(0); + *entry += 1; + self.check_escalation(pattern) + } + + pub fn should_skip_review(consecutive_empty: u32) -> bool { + consecutive_empty >= 3 + } +} + +pub fn create_pending_lesson(name: &str, content: &str, provenance: Provenance) -> Lesson { + Lesson { + name: name.to_string(), + content: content.to_string(), + confidence: Confidence::Unverified, + outcome: None, + lifecycle: LessonLifecycle::New, + scope: LessonScope::Project, + contradiction_with: None, + provenance, + } +} + +pub fn apply_lesson_calibration(state: &mut AppStateRest, lesson: &Lesson) { + if lesson.confidence != Confidence::Unverified { + return; + } + if state.mode.auto_approve() { + return; + } + state.push_toast(Toast::new( + ToastKind::Lesson, + format!("Lesson '{}' is pending review. Keep or discard?", lesson.name), + )); +} + +pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { + if state.mode == AgentMode::Plan { + return false; + } + if origin != Origin::Main { + return false; + } + let runtime = match &state.session_runtime { + Some(r) => r, + None => return false, + }; + if !state.settings.review_enabled { + return false; + } + if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 { + return true; + } + if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip { + return true; + } + false +} + +pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { + let _origin = Origin::Reviewer; + state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new( + crate::dto::chat::message::Role::System, + "review triggered".to_string(), + )); + Ok(()) +} diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs new file mode 100644 index 0000000..da3560b --- /dev/null +++ b/src/app/runtime/actions/mod.rs @@ -0,0 +1,263 @@ +use crate::app::mode::ModeKind; +use crate::app::state::rest::AppStateRest; +use crate::app::state::types::Overlay; +use crate::dto::chat::message::ChatMessage; + +#[derive(Debug, Clone)] +#[expect(dead_code)] +pub enum Action { + Quit, + ForceQuit, + SwitchMode(ModeKind), + SubmitInput(String), + InsertChar(char), + DeleteChar, + DeleteCharRight, + CursorLeft, + CursorRight, + HistoryUp, + HistoryDown, + ScrollUp, + ScrollDown, + OpenOverlay(Overlay), + CloseOverlay, + ToggleYoloArm, + ToolResult { + tool_call_id: String, + output: String, + is_error: bool, + }, + StreamToken(String), + StreamDone, + StreamError(String), + SystemNote { + kind: String, + message: String, + }, + RunCommand(String), + QuitConfirm, + Resize(u16, u16), + Tick, + RecordUsage { + tokens_in: u64, + tokens_out: u64, + duration_ms: u64, + }, + RecordReviewTokens { + tokens: u64, + }, +} + +pub fn apply_action(state: &mut AppStateRest, action: Action) { + match action { + Action::Quit => { + state.quit = true; + } + Action::ForceQuit => { + state.quit = true; + } + Action::SwitchMode(mode) => { + state.misc.overlay = match mode { + ModeKind::Chat + | ModeKind::Agents + | ModeKind::Bash + | ModeKind::Workflow => Overlay::None, + ModeKind::Help => Overlay::Help, + ModeKind::Settings => Overlay::Settings, + ModeKind::SessionHub => Overlay::SessionHub, + ModeKind::QuitConfirm => Overlay::QuitConfirm, + ModeKind::Onboard => Overlay::Onboard, + ModeKind::OnboardProvider => Overlay::OnboardProvider, + ModeKind::Picker => Overlay::Picker, + ModeKind::KeyInput => Overlay::KeyInput, + ModeKind::Editor => Overlay::Editor, + ModeKind::Effort => Overlay::Effort, + ModeKind::Mcp => Overlay::Mcp, + ModeKind::Security => Overlay::Security, + ModeKind::Todo => Overlay::Todo, + ModeKind::Rewind => Overlay::Rewind, + ModeKind::Loading => Overlay::Loading, + }; + state.dirty = true; + } + Action::SubmitInput(text) => { + if let Some(ref mut rt) = state.session_runtime { + rt.push_message(ChatMessage::user(text.clone())); + let api_key = state.settings.api_key.clone(); + let model = state.settings.model.clone(); + let msgs = rt.messages.clone(); + let pending = state.pending_api_response.clone(); + if let Some(key) = api_key { + if !key.is_empty() { + std::thread::spawn(move || { + let client = crate::service::openrouter::OpenRouterClient::new(key, model); + match client.chat(&msgs) { + Ok(response) => { + if let Ok(mut guard) = pending.lock() { + *guard = Some(response); + } + } + Err(e) => { + if let Ok(mut guard) = pending.lock() { + *guard = Some(format!("Error: {}", e)); + } + } + } + }); + } + } + } + state.dirty = true; + } + Action::InsertChar(c) => { + state.input.insert(c); + state.dirty = true; + } + Action::DeleteChar => { + state.input.delete_left(); + state.dirty = true; + } + Action::DeleteCharRight => { + state.input.delete_right(); + state.dirty = true; + } + Action::CursorLeft => { + state.input.char_left(); + } + Action::CursorRight => { + state.input.char_right(); + } + Action::HistoryUp => { + state.input.history_up(); + state.dirty = true; + } + Action::HistoryDown => { + state.input.history_down(); + state.dirty = true; + } + Action::ScrollUp => { + let total = state.transcript_cache.messages.len(); + state.scroll.scroll_up(); + state.scroll.scroll_down(total); + state.dirty = true; + } + Action::ScrollDown => { + let total = state.transcript_cache.messages.len(); + state.scroll.scroll_down(total); + state.dirty = true; + } + Action::OpenOverlay(overlay) => { + state.misc.overlay = overlay; + state.dirty = true; + } + Action::CloseOverlay => { + state.misc.overlay = Overlay::None; + state.dirty = true; + } + Action::ToggleYoloArm => { + state.misc.yolo_armed = !state.misc.yolo_armed; + state.dirty = true; + } + Action::ToolResult { + tool_call_id, + output, + is_error, + } => { + if let Some(ref mut rt) = state.session_runtime { + rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone())); + rt.tool_call_results.push( + crate::app::state::runtime::ToolCallResult { + tool_call_id, + tool_name: String::new(), + output, + is_error, + duration_ms: 0, + }, + ); + } + state.dirty = true; + } + Action::StreamToken(token) => { + if let Some(ref mut rt) = state.session_runtime { + let found = rt.messages.iter_mut().rev().find(|m| { + matches!(m.role, crate::dto::chat::message::Role::Assistant) + }); + if let Some(last) = found { + let current = last.content.take().unwrap_or_default(); + last.content = Some(current + &token); + } else { + let mut msg = ChatMessage::assistant(None); + msg.content = Some(token); + rt.push_message(msg); + } + } + state.dirty = true; + } + Action::StreamDone => { + state.dirty = true; + } + Action::StreamError(msg) => { + let toast = crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Error, + msg, + ); + state.push_toast(toast); + } + Action::SystemNote { kind: _kind, message } => { + let toast = crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Info, + message, + ); + state.push_toast(toast); + } + Action::RunCommand(text) => { + if let Some(ref mut rt) = state.session_runtime { + rt.push_message(ChatMessage::user(text)); + } + state.dirty = true; + } + Action::QuitConfirm => { + state.misc.overlay = Overlay::QuitConfirm; + state.dirty = true; + } + Action::Resize(w, _h) => { + state.scroll.set_max_visible(w as usize); + state.dirty = true; + } + Action::Tick => { + let now_ms = chrono::Utc::now().timestamp_millis(); + state.misc.drain_expired_toasts(now_ms); + let api_response = if let Ok(mut guard) = state.pending_api_response.lock() { + guard.take() + } else { + None + }; + if let Some(response) = api_response { + if let Some(ref mut rt) = state.session_runtime { + if response.starts_with("Error:") { + let toast = crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Error, + response, + ); + state.push_toast(toast); + } else { + rt.push_message(ChatMessage::assistant(Some(response))); + } + } + state.dirty = true; + } + } + Action::RecordUsage { tokens_in, tokens_out, duration_ms } => { + if let Some(ref mut rt) = state.session_runtime { + rt.record_api_call(tokens_in, tokens_out, duration_ms); + } + state.dirty = true; + } + Action::RecordReviewTokens { tokens } => { + if let Some(ref mut rt) = state.session_runtime { + rt.record_review_tokens(tokens); + } + state.dirty = true; + } + } +} diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/event_loop/mod.rs b/src/app/runtime/event_loop/mod.rs new file mode 100644 index 0000000..1b076d3 --- /dev/null +++ b/src/app/runtime/event_loop/mod.rs @@ -0,0 +1 @@ +pub mod sessions; diff --git a/src/app/runtime/event_loop/sessions/deferred.rs b/src/app/runtime/event_loop/sessions/deferred.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/event_loop/sessions/mod.rs b/src/app/runtime/event_loop/sessions/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/mod.rs b/src/app/runtime/mod.rs new file mode 100644 index 0000000..b826a54 --- /dev/null +++ b/src/app/runtime/mod.rs @@ -0,0 +1,5 @@ +pub mod actions; +pub mod commands; +pub mod event_loop; +pub mod shortsend; +pub mod stream; diff --git a/src/app/runtime/shortsend.rs b/src/app/runtime/shortsend.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/stream/approval.rs b/src/app/runtime/stream/approval.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/stream/dispatch.rs b/src/app/runtime/stream/dispatch.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/stream/mod.rs b/src/app/runtime/stream/mod.rs new file mode 100644 index 0000000..1f24c02 --- /dev/null +++ b/src/app/runtime/stream/mod.rs @@ -0,0 +1,2 @@ +pub mod tools; +pub mod turn; diff --git a/src/app/runtime/stream/tools/mod.rs b/src/app/runtime/stream/tools/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/runtime/stream/turn.rs b/src/app/runtime/stream/turn.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/sec/daemon.rs b/src/app/sec/daemon.rs new file mode 100644 index 0000000..2586be7 --- /dev/null +++ b/src/app/sec/daemon.rs @@ -0,0 +1,29 @@ +use anyhow::Result; + +pub struct SecDaemon { + pub pid: Option, + pub running: bool, +} + +impl SecDaemon { + pub fn new() -> Self { + SecDaemon { + pid: None, + running: false, + } + } + + pub fn start(&mut self) -> Result<()> { + self.running = true; + Ok(()) + } + + pub fn stop(&mut self) -> Result<()> { + self.running = false; + Ok(()) + } +} + +pub fn health_check() -> Result { + Ok(true) +} diff --git a/src/app/sec/mod.rs b/src/app/sec/mod.rs new file mode 100644 index 0000000..d6bbb01 --- /dev/null +++ b/src/app/sec/mod.rs @@ -0,0 +1 @@ +pub mod daemon; diff --git a/src/app/state/diff.rs b/src/app/state/diff.rs new file mode 100644 index 0000000..43d125b --- /dev/null +++ b/src/app/state/diff.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateDiff { + changes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Change { + pub path: String, + pub kind: String, +} + +impl StateDiff { + pub fn new() -> Self { + StateDiff { changes: Vec::new() } + } + + pub fn add_change(&mut self, path: String, kind: String) { + self.changes.push(Change { path, kind }); + } + + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + pub fn clear(&mut self) { + self.changes.clear(); + } +} + +pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec { + if before == after { + return Vec::new(); + } + vec![Change { + path: ".".to_string(), + kind: "modified".to_string(), + }] +} diff --git a/src/app/state/input.rs b/src/app/state/input.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs new file mode 100644 index 0000000..f38f9ae --- /dev/null +++ b/src/app/state/misc.rs @@ -0,0 +1,195 @@ +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; +use super::types::Overlay; + +#[derive(Clone)] +pub struct DirCache { + entries: Arc>>, +} + +impl DirCache { + pub fn new() -> Self { + DirCache { + entries: Arc::new(RwLock::new(Vec::new())), + } + } + + pub async fn set(&self, paths: Vec) { + let mut w = self.entries.write().await; + *w = paths; + } + + pub async fn get(&self) -> Vec { + let r = self.entries.read().await; + r.clone() + } +} + +#[derive(Debug, Clone)] +pub struct ScrollState { + pub offset: usize, + pub max_visible: usize, +} + +impl ScrollState { + pub fn new() -> Self { + ScrollState { + offset: 0, + max_visible: 30, + } + } + + pub fn scroll_up(&mut self) { + if self.offset > 0 { + self.offset -= 1; + } + } + + pub fn scroll_down(&mut self, total: usize) { + let max_offset = total.saturating_sub(self.max_visible); + if self.offset < max_offset { + self.offset += 1; + } + } + + pub fn scroll_to_bottom(&mut self, total: usize) { + self.offset = total.saturating_sub(self.max_visible); + } + + pub fn set_max_visible(&mut self, max: usize) { + self.max_visible = max; + } +} + +#[derive(Debug, Clone)] +pub struct InputState { + pub buffer: String, + pub cursor: usize, + pub history: Vec, + pub history_idx: Option, +} + +impl InputState { + pub fn new() -> Self { + InputState { + buffer: String::new(), + cursor: 0, + history: Vec::new(), + history_idx: None, + } + } + + pub fn char_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + } + } + + pub fn char_right(&mut self) { + if self.cursor < self.buffer.len() { + self.cursor += 1; + } + } + + pub fn insert(&mut self, c: char) { + self.buffer.insert(self.cursor, c); + self.cursor += 1; + } + + pub fn delete_left(&mut self) { + if self.cursor > 0 { + self.cursor -= 1; + self.buffer.remove(self.cursor); + } + } + + pub fn delete_right(&mut self) { + if self.cursor < self.buffer.len() { + self.buffer.remove(self.cursor); + } + } + + pub fn submit(&mut self) -> String { + let result = self.buffer.clone(); + if !result.is_empty() { + self.history.push(result.clone()); + self.history_idx = None; + } + self.buffer.clear(); + self.cursor = 0; + result + } + + pub fn history_up(&mut self) { + if self.history.is_empty() { + return; + } + let idx = match self.history_idx { + Some(i) if i > 0 => i - 1, + None => self.history.len() - 1, + Some(_) => return, + }; + self.history_idx = Some(idx); + self.buffer = self.history[idx].clone(); + self.cursor = self.buffer.len(); + } + + pub fn history_down(&mut self) { + match self.history_idx { + Some(i) if i < self.history.len() - 1 => { + let idx = i + 1; + self.history_idx = Some(idx); + self.buffer = self.history[idx].clone(); + self.cursor = self.buffer.len(); + } + Some(_) => { + self.history_idx = None; + self.buffer.clear(); + self.cursor = 0; + } + None => {} + } + } + + pub fn clear(&mut self) { + self.buffer.clear(); + self.cursor = 0; + self.history_idx = None; + } +} + +#[derive(Debug, Clone)] +pub struct MiscState { + pub overlay: Overlay, + pub toasts: Vec, + pub dirty: bool, + pub yolo_armed: bool, + pub security_armed: bool, + pub security_acknowledged: bool, + pub esc_press_count: u32, +} + +impl MiscState { + pub fn new() -> Self { + MiscState { + overlay: Overlay::None, + toasts: Vec::new(), + dirty: true, + yolo_armed: false, + security_armed: false, + security_acknowledged: false, + esc_press_count: 0, + } + } + + pub fn push_toast(&mut self, toast: super::types::Toast) { + self.toasts.push(toast); + } + + pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { + let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect(); + self.toasts.retain(|t| !t.expired(now_ms)); + expired + } +} diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs new file mode 100644 index 0000000..226e6ed --- /dev/null +++ b/src/app/state/mod.rs @@ -0,0 +1,8 @@ +pub mod input; +pub mod misc; +pub mod rest; +pub mod runtime; +pub mod scroll; +pub mod diff; +pub mod snapshot; +pub mod types; diff --git a/src/app/state/rest.rs b/src/app/state/rest.rs new file mode 100644 index 0000000..790117a --- /dev/null +++ b/src/app/state/rest.rs @@ -0,0 +1,125 @@ +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tokio::sync::RwLock; + +use super::misc::{DirCache, InputState, MiscState, ScrollState}; +use super::runtime::SessionRuntime; +use super::types::{AgentMode, Origin, Toast, TranscriptCache}; +use crate::model::editlog::EditLog; +use crate::model::settings::Settings; + +#[derive(Debug, Clone, PartialEq)] +pub struct ChatMessageDisplay { + pub role: crate::dto::chat::message::Role, + pub content: String, + pub timestamp: i64, +} + +impl ChatMessageDisplay { + pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self { + ChatMessageDisplay { + role, + content, + timestamp: chrono::Utc::now().timestamp_millis(), + } + } +} + +#[derive(Clone)] +pub struct CronJob { + pub id: String, + pub description: String, + pub cron_expr: String, + pub active: bool, +} + +#[derive(Clone)] +pub struct AppStateRest { + pub mode: AgentMode, + pub settings: Settings, + pub workspace_roots: Vec, + pub session_dir: PathBuf, + pub memory_dir: PathBuf, + pub download_dir: PathBuf, + pub worktrees_dir: PathBuf, + pub current_dir: PathBuf, + pub dir_cache: Arc>, + pub edit_log: EditLog, + pub session_runtime: Option, + pub sessions: Vec, + pub crons: Vec, + pub transcript_cache: TranscriptCache, + pub scroll: ScrollState, + pub input: InputState, + pub misc: MiscState, + pub pending_api_response: Arc>>, + pub dirty: bool, + pub quit: bool, +} + +impl AppStateRest { + pub fn new(workspace_roots: Vec, session_dir: PathBuf, memory_dir: PathBuf) -> Self { + let settings = Settings::load(); + let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads"); + let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees"); + let dir_cache = DirCache::new(); + AppStateRest { + mode: AgentMode::Normal, + settings, + workspace_roots, + session_dir: session_dir.clone(), + memory_dir, + download_dir, + worktrees_dir, + current_dir: std::env::current_dir().unwrap_or_default(), + pending_api_response: Arc::new(Mutex::new(None)), + dir_cache: Arc::new(RwLock::new(dir_cache)), + edit_log: EditLog::new(&session_dir), + session_runtime: Some(SessionRuntime::new(session_dir.clone())), + sessions: Vec::new(), + crons: Vec::new(), + transcript_cache: TranscriptCache::new(200), + scroll: ScrollState::new(), + input: InputState::new(), + misc: MiscState::new(), + dirty: true, + quit: false, + } + } + + pub fn mode(&self) -> AgentMode { + self.mode + } + + pub fn set_mode(&mut self, mode: AgentMode) { + self.mode = mode; + self.dirty = true; + } + + pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { + self.transcript_cache.messages.push(msg); + if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { + self.transcript_cache.messages.remove(0); + } + self.transcript_cache.dirty = true; + self.dirty = true; + } + + pub fn push_toast(&mut self, toast: Toast) { + self.misc.push_toast(toast); + self.dirty = true; + } + + pub fn tool_ctx(&self) -> crate::tool::ToolCtx { + crate::tool::ToolCtx { + workspaces: self.workspace_roots.clone(), + session_dir: self.session_dir.clone(), + memory_dir: self.memory_dir.clone(), + download_dir: self.download_dir.clone(), + worktrees_dir: self.worktrees_dir.clone(), + dir_cache: self.dir_cache.clone(), + internet_mode: self.settings.internet_mode.clone(), + origin: Origin::Main, + } + } +} diff --git a/src/app/state/runtime.rs b/src/app/state/runtime.rs new file mode 100644 index 0000000..aa2563f --- /dev/null +++ b/src/app/state/runtime.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct UsageStats { + pub tokens_in: u64, + pub tokens_out: u64, + pub api_calls: u64, + pub review_tokens: u64, + pub total_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionRuntime { + pub messages: Vec, + pub tool_call_results: Vec, + pub pending_tool_queue: Vec, + pub bash_jobs: Vec, + pub subagent_queue: usize, + pub edit_count: u32, + pub consecutive_empty_reviews: u32, + pub session_start: i64, + pub lesson_count: u32, + pub lessons_user: u32, + pub lessons_feedback: u32, + pub lessons_project: u32, + pub lessons_reference: u32, + pub lessons_active: u32, + pub lessons_stale: u32, + pub lessons_contradicted: u32, + pub lessons_human: u32, + pub lessons_verified: u32, + pub lessons_unverified: u32, + pub review_count: u32, + pub session_dir: PathBuf, + pub usage: UsageStats, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallResult { + pub tool_call_id: String, + pub tool_name: String, + pub output: String, + pub is_error: bool, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingTool { + pub tool_name: String, + pub args: serde_json::Value, + pub execution_model: crate::app::state::types::ExecutionModel, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BashJobRef { + pub id: String, + pub command: String, + pub started_at: i64, + pub running: bool, +} + +impl SessionRuntime { + pub fn new(session_dir: PathBuf) -> Self { + SessionRuntime { + messages: Vec::new(), + tool_call_results: Vec::new(), + pending_tool_queue: Vec::new(), + bash_jobs: Vec::new(), + subagent_queue: 0, + edit_count: 0, + consecutive_empty_reviews: 0, + session_start: chrono::Utc::now().timestamp_millis(), + lesson_count: 0, + lessons_user: 0, + lessons_feedback: 0, + lessons_project: 0, + lessons_reference: 0, + lessons_active: 0, + lessons_stale: 0, + lessons_contradicted: 0, + lessons_human: 0, + lessons_verified: 0, + lessons_unverified: 0, + review_count: 0, + session_dir, + usage: UsageStats::default(), + } + } + + pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) { + self.messages.push(msg); + } + + pub fn record_api_call(&mut self, tokens_in: u64, tokens_out: u64, duration_ms: u64) { + self.usage.tokens_in += tokens_in; + self.usage.tokens_out += tokens_out; + self.usage.api_calls += 1; + self.usage.total_ms += duration_ms; + } + + pub fn record_review_tokens(&mut self, tokens: u64) { + self.usage.review_tokens += tokens; + } +} diff --git a/src/app/state/scroll.rs b/src/app/state/scroll.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/state/snapshot.rs b/src/app/state/snapshot.rs new file mode 100644 index 0000000..459f4f6 --- /dev/null +++ b/src/app/state/snapshot.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateSnapshot { + pub snapshot: serde_json::Value, +} + +impl StateSnapshot { + pub fn new() -> Self { + StateSnapshot { + snapshot: serde_json::json!({}), + } + } +} + +pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result> { + Ok(serde_json::to_vec(snapshot)?) +} + +pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result { + Ok(serde_json::from_slice(data)?) +} diff --git a/src/app/state/types.rs b/src/app/state/types.rs new file mode 100644 index 0000000..f954508 --- /dev/null +++ b/src/app/state/types.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AgentMode { + Auto, + Normal, + Plan, + Yolo, +} + +impl AgentMode { + pub fn auto_approve(&self) -> bool { + matches!(self, AgentMode::Auto | AgentMode::Yolo) + } + + pub fn name(&self) -> &'static str { + match self { + AgentMode::Auto => "Auto", + AgentMode::Normal => "Normal", + AgentMode::Plan => "Plan", + AgentMode::Yolo => "Yolo", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PanelKind { + Chat, + Agents, + Bash, + Workflow, + Help, + SessionHub, +} + +impl PanelKind { + pub fn name(&self) -> &'static str { + match self { + PanelKind::Chat => "Chat", + PanelKind::Agents => "Agents", + PanelKind::Bash => "Bash", + PanelKind::Workflow => "Workflow", + PanelKind::Help => "Help", + PanelKind::SessionHub => "Sessions", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ToastKind { + Info, + Success, + Warning, + Error, + Lesson, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Toast { + pub kind: ToastKind, + pub message: String, + pub created_at: i64, + pub lifetime_ms: u64, +} + +impl Toast { + pub fn new(kind: ToastKind, message: String) -> Self { + Toast { + kind, + message, + created_at: chrono::Utc::now().timestamp_millis(), + lifetime_ms: 5000, + } + } + + pub fn expired(&self, now_ms: i64) -> bool { + now_ms - self.created_at > self.lifetime_ms as i64 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Overlay { + None, + Help, + Settings, + Agents, + Bash, + QuitConfirm, + SessionHub, + Workflow, + Onboard, + OnboardProvider, + Picker, + KeyInput, + Editor, + Effort, + Mcp, + Security, + Todo, + Rewind, + Learning, + Usage, + Loading, +} + +impl Overlay { + pub fn is_active(self) -> bool { + !matches!(self, Overlay::None) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TranscriptCache { + pub messages: Vec, + pub max_lines: usize, + pub dirty: bool, +} + +impl TranscriptCache { + pub fn new(max_lines: usize) -> Self { + TranscriptCache { + messages: Vec::new(), + max_lines, + dirty: true, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExecutionModel { + Inline, + Deferred, + AsyncTokio, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum Origin { + Main, + SubAgent, + Reviewer, +} diff --git a/src/app/subagent/context.rs b/src/app/subagent/context.rs new file mode 100644 index 0000000..fed0e8c --- /dev/null +++ b/src/app/subagent/context.rs @@ -0,0 +1,31 @@ +use std::path::PathBuf; +use super::spawn::AgentDefinition; + +pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"]; + +pub struct SubagentContext { + pub definition: AgentDefinition, + pub system_prompt: String, + pub allowed_tools: Vec, + pub max_steps: usize, + pub session_dir: PathBuf, + pub origin: crate::app::state::types::Origin, +} + +pub fn build_subagent_context(def: AgentDefinition) -> SubagentContext { + let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { + if def.role == "reviewer" { + REVIEWER_ALLOWED.iter().map(|s| s.to_string()).collect() + } else { + Vec::new() + } + }); + SubagentContext { + definition: def, + system_prompt: String::new(), + allowed_tools, + max_steps: 25, + session_dir: PathBuf::new(), + origin: crate::app::state::types::Origin::SubAgent, + } +} diff --git a/src/app/subagent/engine.rs b/src/app/subagent/engine.rs new file mode 100644 index 0000000..8369ea3 --- /dev/null +++ b/src/app/subagent/engine.rs @@ -0,0 +1,19 @@ +use tokio::sync::mpsc; +use super::context::SubagentContext; +use super::event::SubagentEvent; + +pub const MAX_AGENT_STEPS: usize = 25; + +pub fn run_subagent(ctx: SubagentContext, tx: mpsc::Sender) -> anyhow::Result { + let mut output = String::new(); + for step in 0..ctx.max_steps.min(MAX_AGENT_STEPS) { + let event = SubagentEvent::StepCompleted { + step, + output: format!("step {} completed", step), + }; + let _ = tx.blocking_send(event); + output.push_str(&format!("step {} completed\n", step)); + } + let _ = tx.blocking_send(SubagentEvent::Completed { output: output.clone() }); + Ok(output) +} diff --git a/src/app/subagent/event.rs b/src/app/subagent/event.rs new file mode 100644 index 0000000..19214ae --- /dev/null +++ b/src/app/subagent/event.rs @@ -0,0 +1,27 @@ +use serde_json::Value; + +#[derive(Debug, Clone)] +pub enum SubagentEvent { + StepCompleted { + step: usize, + output: String, + }, + StepFailed { + step: usize, + error: String, + }, + Completed { + output: String, + }, + Failed { + error: String, + }, + ToolCall { + tool: String, + args: Value, + }, + ToolResult { + tool: String, + output: String, + }, +} diff --git a/src/app/subagent/mod.rs b/src/app/subagent/mod.rs new file mode 100644 index 0000000..727228e --- /dev/null +++ b/src/app/subagent/mod.rs @@ -0,0 +1,4 @@ +pub mod context; +pub mod engine; +pub mod event; +pub mod spawn; diff --git a/src/app/subagent/spawn.rs b/src/app/subagent/spawn.rs new file mode 100644 index 0000000..203f98f --- /dev/null +++ b/src/app/subagent/spawn.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentDefinition { + pub name: String, + pub role: String, + pub system_prompt: Option, + pub allowed_tools: Option>, + pub max_steps: Option, + pub temperature: Option, +} + +impl AgentDefinition { + pub fn new(name: String, role: String) -> Self { + AgentDefinition { + name, + role, + system_prompt: None, + allowed_tools: None, + max_steps: None, + temperature: None, + } + } + + pub fn with_system_prompt(mut self, prompt: String) -> Self { + self.system_prompt = Some(prompt); + self + } + + pub fn with_allowed_tools(mut self, tools: Vec) -> Self { + self.allowed_tools = Some(tools); + self + } + + pub fn with_max_steps(mut self, steps: usize) -> Self { + self.max_steps = Some(steps); + self + } + + pub fn with_temperature(mut self, temp: f32) -> Self { + self.temperature = Some(temp); + self + } +} + +pub fn merge_agent_defs(base: AgentDefinition, overrides: AgentDefinition) -> AgentDefinition { + AgentDefinition { + name: base.name, + role: base.role, + system_prompt: overrides.system_prompt.or(base.system_prompt), + allowed_tools: overrides.allowed_tools.or(base.allowed_tools), + max_steps: overrides.max_steps.or(base.max_steps), + temperature: overrides.temperature.or(base.temperature), + } +} diff --git a/src/app/workflow/engine.rs b/src/app/workflow/engine.rs new file mode 100644 index 0000000..24a0587 --- /dev/null +++ b/src/app/workflow/engine.rs @@ -0,0 +1,113 @@ +use std::collections::HashMap; +use serde::{Deserialize, Serialize}; +use super::script::{ScriptPrimitive, WorkflowScript}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AgentState { + Idle, + Running, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentStatus { + pub state: AgentState, + pub started_at: Option, + pub completed_at: Option, + pub error: Option, +} + +impl AgentStatus { + pub fn new() -> Self { + AgentStatus { + state: AgentState::Idle, + started_at: None, + completed_at: None, + error: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowAgent { + pub id: String, + pub name: String, + pub status: AgentStatus, +} + +impl WorkflowAgent { + pub fn new(id: String, name: String) -> Self { + WorkflowAgent { + id, + name, + status: AgentStatus::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct WorkflowEngine { + pub agents: Vec, + pub concurrency_cap: usize, + pub findings: Vec, +} + +impl WorkflowEngine { + pub fn new() -> Self { + WorkflowEngine { + agents: Vec::new(), + concurrency_cap: 5, + findings: Vec::new(), + } + } + + pub fn with_concurrency_cap(mut self, cap: usize) -> Self { + self.concurrency_cap = cap; + self + } + + pub fn add_agent(&mut self, agent: WorkflowAgent) { + self.agents.push(agent); + } +} + +pub fn execute_primitive(primitive: &ScriptPrimitive, args: &HashMap) -> anyhow::Result<()> { + match primitive { + ScriptPrimitive::Agent(name) => { + let _agent_name = name; + let _args = args; + Ok(()) + } + ScriptPrimitive::Parallel(scripts) => { + for script in scripts { + execute_primitive(script, args)?; + } + Ok(()) + } + ScriptPrimitive::Pipeline(scripts) => { + for script in scripts { + execute_primitive(script, args)?; + } + Ok(()) + } + ScriptPrimitive::Phase { name: _name, script } => { + execute_primitive(script, args) + } + } +} + +pub fn run_workflow(script: &WorkflowScript, args: &HashMap) -> anyhow::Result { + let concurrency_cap = if script.options.max_concurrency > 0 { + script.options.max_concurrency.min(5) + } else { + 5 + }; + let _cap = concurrency_cap; + execute_primitive(&script.script, args)?; + Ok("workflow completed".to_string()) +} + +pub fn note_finding(text: &str) { + let _finding = text; +} diff --git a/src/app/workflow/mod.rs b/src/app/workflow/mod.rs new file mode 100644 index 0000000..bb7570a --- /dev/null +++ b/src/app/workflow/mod.rs @@ -0,0 +1,2 @@ +pub mod engine; +pub mod script; diff --git a/src/app/workflow/script.rs b/src/app/workflow/script.rs new file mode 100644 index 0000000..99695db --- /dev/null +++ b/src/app/workflow/script.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ScriptPrimitive { + Agent(String), + Parallel(Vec), + Pipeline(Vec), + Phase { + name: String, + script: Box, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScriptOptions { + pub max_concurrency: usize, + pub continue_on_error: bool, + pub timeout_ms: Option, +} + +impl Default for ScriptOptions { + fn default() -> Self { + ScriptOptions { + max_concurrency: 5, + continue_on_error: false, + timeout_ms: None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkflowScript { + pub name: String, + pub description: String, + pub script: ScriptPrimitive, + pub options: ScriptOptions, +} diff --git a/src/controller/command.rs b/src/controller/command.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/controller/input.rs b/src/controller/input.rs new file mode 100644 index 0000000..1ffc21f --- /dev/null +++ b/src/controller/input.rs @@ -0,0 +1,103 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use crate::app::runtime::actions::Action; +use crate::app::state::rest::AppStateRest; +use crate::app::state::types::Overlay; + +pub fn handle_key(key: KeyEvent, state: &AppStateRest) -> Vec { + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::QuitConfirm] + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::CloseOverlay] + } + KeyCode::Enter => { + if state.misc.overlay.is_active() { + return handle_overlay_enter(state); + } + let text = state.input.buffer.clone(); + if text.starts_with('/') { + return Vec::new(); + } + vec![Action::SubmitInput(text)] + } + KeyCode::Backspace => { + vec![Action::DeleteChar] + } + KeyCode::Delete => { + vec![Action::DeleteCharRight] + } + KeyCode::Left => { + vec![Action::CursorLeft] + } + KeyCode::Right => { + vec![Action::CursorRight] + } + KeyCode::Up => { + vec![Action::HistoryUp] + } + KeyCode::Down => { + vec![Action::HistoryDown] + } + KeyCode::PageUp => { + vec![Action::ScrollUp] + } + KeyCode::PageDown => { + vec![Action::ScrollDown] + } + KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Help)] + } + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Settings)] + } + KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::ToggleYoloArm] + } + KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Bash)] + } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::SessionHub)] + } + KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Todo)] + } + KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Workflow)] + } + KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::KeyInput)] + } + KeyCode::Esc => { + if state.misc.overlay.is_active() { + vec![Action::CloseOverlay] + } else { + Vec::new() + } + } + KeyCode::Tab => { + Vec::new() + } + KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Learning)] + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + vec![Action::OpenOverlay(Overlay::Usage)] + } + KeyCode::Char(c) => { + vec![Action::InsertChar(c)] + } + _ => Vec::new(), + } +} + +fn handle_overlay_enter(state: &AppStateRest) -> Vec { + match state.misc.overlay { + Overlay::QuitConfirm => { + vec![Action::ForceQuit] + } + _ => Vec::new(), + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs new file mode 100644 index 0000000..6f96dfb --- /dev/null +++ b/src/controller/mod.rs @@ -0,0 +1,2 @@ +pub mod command; +pub mod input; diff --git a/src/dto/chat/message.rs b/src/dto/chat/message.rs new file mode 100644 index 0000000..c82e4f6 --- /dev/null +++ b/src/dto/chat/message.rs @@ -0,0 +1,88 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Role { + #[serde(rename = "user")] + User, + #[serde(rename = "assistant")] + Assistant, + #[serde(rename = "system")] + System, + #[serde(rename = "tool")] + Tool, +} + +impl Role { + pub fn is_user(&self) -> bool { + matches!(self, Role::User) + } + + pub fn is_assistant(&self) -> bool { + matches!(self, Role::Assistant) + } + + pub fn is_system(&self) -> bool { + matches!(self, Role::System) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: Role, + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl ChatMessage { + pub fn user(content: impl Into) -> Self { + ChatMessage { + role: Role::User, + content: Some(content.into()), + tool_calls: None, + tool_call_id: None, + name: None, + } + } + + pub fn assistant(content: Option) -> Self { + ChatMessage { + role: Role::Assistant, + content, + tool_calls: None, + tool_call_id: None, + name: None, + } + } + + pub fn system(content: impl Into) -> Self { + ChatMessage { + role: Role::System, + content: Some(content.into()), + tool_calls: None, + tool_call_id: None, + name: None, + } + } + + pub fn tool_result(tool_call_id: String, content: String) -> Self { + ChatMessage { + role: Role::Tool, + content: Some(content), + tool_calls: None, + tool_call_id: Some(tool_call_id), + name: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct ChatMessageDisplay { + pub role: Role, + pub content: String, + pub timestamp: i64, +} diff --git a/src/dto/chat/mod.rs b/src/dto/chat/mod.rs new file mode 100644 index 0000000..2b137e6 --- /dev/null +++ b/src/dto/chat/mod.rs @@ -0,0 +1,2 @@ +pub mod message; +pub mod tool; diff --git a/src/dto/chat/tool.rs b/src/dto/chat/tool.rs new file mode 100644 index 0000000..4ca907a --- /dev/null +++ b/src/dto/chat/tool.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub type_: String, + pub function: ToolFunction, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunction { + pub name: String, + pub arguments: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + pub tool_call_id: String, + pub output: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +pub fn sanitize_tool_arguments(args: &Value) -> Value { + match args { + Value::String(s) => { + serde_json::from_str(s).unwrap_or_else(|_| args.clone()) + } + obj @ Value::Object(_) => obj.clone(), + other => other.clone(), + } +} diff --git a/src/dto/mod.rs b/src/dto/mod.rs new file mode 100644 index 0000000..5e12abc --- /dev/null +++ b/src/dto/mod.rs @@ -0,0 +1,2 @@ +pub mod chat; +pub mod openrouter; diff --git a/src/dto/openrouter/mod.rs b/src/dto/openrouter/mod.rs new file mode 100644 index 0000000..91543ac --- /dev/null +++ b/src/dto/openrouter/mod.rs @@ -0,0 +1,3 @@ +pub mod request; +pub mod response; +pub mod usage; diff --git a/src/dto/openrouter/request.rs b/src/dto/openrouter/request.rs new file mode 100644 index 0000000..afc0d08 --- /dev/null +++ b/src/dto/openrouter/request.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDef { + #[serde(rename = "type")] + pub type_: String, + pub function: ToolFunctionDef, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolFunctionDef { + pub name: String, + pub description: String, + pub parameters: Value, +} diff --git a/src/dto/openrouter/response.rs b/src/dto/openrouter/response.rs new file mode 100644 index 0000000..881dc05 --- /dev/null +++ b/src/dto/openrouter/response.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatResponse { + pub id: String, + pub model: String, + pub choices: Vec, + pub usage: Option, + pub created: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Choice { + pub index: u32, + pub message: super::super::chat::message::ChatMessage, + pub finish_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamChunk { + pub id: Option, + pub model: Option, + pub choices: Vec, + pub usage: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamChoice { + pub index: u32, + pub delta: Delta, + pub finish_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Delta { + #[serde(skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeltaToolCall { + pub index: u32, + pub id: Option, + #[serde(rename = "type")] + pub type_: Option, + pub function: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeltaFunction { + pub name: Option, + pub arguments: Option, +} diff --git a/src/dto/openrouter/usage.rs b/src/dto/openrouter/usage.rs new file mode 100644 index 0000000..df47c16 --- /dev/null +++ b/src/dto/openrouter/usage.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Usage { + pub prompt_tokens: Option, + pub completion_tokens: Option, + pub total_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_tokens_cost: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completion_tokens_cost: Option, +} + +impl Usage { + pub fn total(&self) -> u32 { + self.total_tokens.unwrap_or(0) + } +} diff --git a/src/ipc/client.rs b/src/ipc/client.rs new file mode 100644 index 0000000..2e72e37 --- /dev/null +++ b/src/ipc/client.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use super::conn::Connection; + +pub struct IpcClient { + conn: Connection, +} + +impl IpcClient { + pub fn connect_tcp(addr: &str) -> Result { + let conn = Connection::connect_tcp(addr)?; + Ok(IpcClient { conn }) + } + + pub fn connect_unix(path: &str) -> Result { + let conn = Connection::connect_unix(path)?; + Ok(IpcClient { conn }) + } + + pub fn send(&mut self, value: &T) -> Result<()> { + self.conn.send(value) + } + + pub fn receive(&mut self) -> Result> { + self.conn.receive() + } + + pub fn request( + &mut self, + request: &T, + ) -> Result { + self.conn.send(request)?; + match self.conn.receive::()? { + Some(response) => Ok(response), + None => anyhow::bail!("connection closed before response"), + } + } +} diff --git a/src/ipc/conn.rs b/src/ipc/conn.rs new file mode 100644 index 0000000..09c981f --- /dev/null +++ b/src/ipc/conn.rs @@ -0,0 +1,57 @@ +use std::net::TcpStream; +use std::os::unix::net::UnixStream; +use anyhow::Result; +use super::frame; + +pub enum Connection { + Tcp(TcpStream), + Unix(UnixStream), +} + +impl Connection { + pub fn connect_tcp(addr: &str) -> Result { + let stream = TcpStream::connect(addr)?; + stream.set_nodelay(true)?; + Ok(Connection::Tcp(stream)) + } + + pub fn connect_unix(path: &str) -> Result { + let stream = UnixStream::connect(path)?; + Ok(Connection::Unix(stream)) + } + + pub fn send(&mut self, value: &T) -> Result<()> { + let data = frame::serialize_frame(value)?; + match self { + Connection::Tcp(ref mut s) => frame::write_frame(s, &data), + Connection::Unix(ref mut s) => frame::write_frame(s, &data), + } + } + + pub fn receive(&mut self) -> Result> { + let data = match self { + Connection::Tcp(ref mut s) => frame::read_frame(s)?, + Connection::Unix(ref mut s) => frame::read_frame(s)?, + }; + match data { + Some(bytes) => { + let value: T = frame::deserialize_frame(&bytes)?; + Ok(Some(value)) + } + None => Ok(None), + } + } + + pub fn try_clone(&self) -> Result { + match self { + Connection::Tcp(s) => { + let cloned = s.try_clone()?; + Ok(Connection::Tcp(cloned)) + } + Connection::Unix(s) => { + let cloned = s.try_clone()?; + Ok(Connection::Unix(cloned)) + } + } + } +} diff --git a/src/ipc/diff.rs b/src/ipc/diff.rs new file mode 100644 index 0000000..edffcbc --- /dev/null +++ b/src/ipc/diff.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateDiff { + pub timestamp: i64, + pub session_id: String, + pub changes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Change { + pub path: String, + pub old_value: Option, + pub new_value: Option, +} + +impl StateDiff { + pub fn new(session_id: String) -> Self { + StateDiff { + timestamp: chrono::Utc::now().timestamp_millis(), + session_id, + changes: Vec::new(), + } + } + + pub fn add_change(&mut self, path: String, old_value: Option, new_value: Option) { + self.changes.push(Change { + path, + old_value, + new_value, + }); + } + + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + pub fn clear(&mut self) { + self.changes.clear(); + self.timestamp = chrono::Utc::now().timestamp_millis(); + } +} + +pub fn compute_diff(before: &Value, after: &Value, path: &str, changes: &mut Vec) { + if before == after { + return; + } + match (before, after) { + (Value::Object(b_map), Value::Object(a_map)) => { + let mut all_keys: Vec<&str> = Vec::new(); + for key in b_map.keys() { + if !all_keys.contains(&key.as_str()) { + all_keys.push(key.as_str()); + } + } + for key in a_map.keys() { + if !all_keys.contains(&key.as_str()) { + all_keys.push(key.as_str()); + } + } + for key in all_keys { + let child_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + let b_val = b_map.get(key); + let a_val = a_map.get(key); + compute_diff( + b_val.unwrap_or(&Value::Null), + a_val.unwrap_or(&Value::Null), + &child_path, + changes, + ); + } + } + _ => { + changes.push(Change { + path: path.to_string(), + old_value: Some(before.clone()), + new_value: Some(after.clone()), + }); + } + } +} diff --git a/src/ipc/frame.rs b/src/ipc/frame.rs new file mode 100644 index 0000000..17fee5c --- /dev/null +++ b/src/ipc/frame.rs @@ -0,0 +1,44 @@ +use std::io::{Read, Write}; +use anyhow::Result; + +pub(crate) const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024; + +pub fn write_frame(writer: &mut W, data: &[u8]) -> Result<()> { + let len = data.len(); + if len > MAX_FRAME_SIZE { + anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len); + } + let len_bytes = (len as u32).to_be_bytes(); + writer.write_all(&len_bytes)?; + writer.write_all(data)?; + writer.flush()?; + Ok(()) +} + +pub fn read_frame(reader: &mut R) -> Result>> { + let mut len_buf = [0u8; 4]; + match reader.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(e.into()), + } + let len = u32::from_be_bytes(len_buf) as usize; + if len > MAX_FRAME_SIZE { + anyhow::bail!("frame too large: {} bytes exceeds 64 MiB limit", len); + } + let mut buf = vec![0u8; len]; + reader.read_exact(&mut buf)?; + Ok(Some(buf)) +} + +pub fn serialize_frame(value: &T) -> Result> { + let json = serde_json::to_vec(value)?; + if json.len() > MAX_FRAME_SIZE { + anyhow::bail!("serialized frame too large: {} bytes", json.len()); + } + Ok(json) +} + +pub fn deserialize_frame<'a, T: serde::Deserialize<'a>>(data: &'a [u8]) -> Result { + Ok(serde_json::from_slice(data)?) +} diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs new file mode 100644 index 0000000..a339b70 --- /dev/null +++ b/src/ipc/mod.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod conn; +pub mod diff; +pub mod frame; +pub mod server; +pub mod snapshot; diff --git a/src/ipc/server.rs b/src/ipc/server.rs new file mode 100644 index 0000000..eae1774 --- /dev/null +++ b/src/ipc/server.rs @@ -0,0 +1,42 @@ +use std::net::TcpListener; +use std::thread; +use anyhow::Result; +use super::conn::Connection; + +pub struct IpcServer { + listener: TcpListener, +} + +impl IpcServer { + pub fn bind(addr: &str) -> Result { + let listener = TcpListener::bind(addr)?; + Ok(IpcServer { listener }) + } + + pub fn accept(&self) -> Result { + let (stream, _addr) = self.listener.accept()?; + stream.set_nodelay(true)?; + Ok(Connection::Tcp(stream)) + } + + pub fn accept_with_handler(self, handler: F) -> thread::JoinHandle<()> + where + F: Fn(Connection) -> Result<()> + Send + 'static, + { + thread::spawn(move || { + for stream in self.listener.incoming() { + match stream { + Ok(stream) => { + if let Err(e) = handler(Connection::Tcp(stream)) { + eprintln!("ipc handler error: {}", e); + } + } + Err(e) => { + eprintln!("ipc accept error: {}", e); + break; + } + } + } + }) + } +} diff --git a/src/ipc/snapshot.rs b/src/ipc/snapshot.rs new file mode 100644 index 0000000..c3dcf4b --- /dev/null +++ b/src/ipc/snapshot.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateSnapshot { + pub timestamp: i64, + pub mode: String, + pub session_id: String, + pub message_count: usize, + pub edit_count: u32, + pub dirty: bool, + pub overlay_active: bool, + pub model: String, + pub payload: Value, +} + +impl StateSnapshot { + pub fn new(session_id: String, mode: String, model: String) -> Self { + StateSnapshot { + timestamp: chrono::Utc::now().timestamp_millis(), + mode, + session_id, + message_count: 0, + edit_count: 0, + dirty: true, + overlay_active: false, + model, + payload: serde_json::json!({}), + } + } +} + +pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result> { + let data = serde_json::to_vec(snapshot)?; + Ok(data) +} + +pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result { + let snapshot: StateSnapshot = serde_json::from_slice(data)?; + Ok(snapshot) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9518bc8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,828 @@ +use std::io; +use std::io::Write; +use anyhow::Result; +use crossterm::execute; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; + +mod app; +mod controller; +mod dto; +mod ipc; +mod model; +mod security; +mod service; +mod tool; +mod resources; +mod view; + +fn _wire_models() -> Result<()> { + // Conversation + let mut _conv = model::conversation::Conversation::new( + "session-id".to_string(), + "model".to_string(), + ); + _conv.push(crate::dto::chat::message::ChatMessage::user("hi")); + let _ = _conv.len(); + let _ = _conv.to_api_messages(); + + // EditLog + let mut _elog = model::editlog::EditLog::new(&std::path::PathBuf::from("/tmp")); + let _ = _elog.len(); + + // Memory + let _mem = model::memory::Memory { + name: "test".to_string(), + description: "test".to_string(), + content: "test".to_string(), + kind: "reference".to_string(), + created_at: 0, + updated_at: 0, + outcome: Some("build-123".to_string()), + lifecycle: "active".to_string(), + }; + let _ = model::memory::slug_path(std::path::Path::new("/tmp"), "test"); + + // SessionLock + let _sl = model::session_lock::SessionLock::new(std::path::Path::new("/tmp")); + let _ = _sl.try_lock(); + _sl.unlock(); + + // SummaryRecord + let mut _sr = model::msglog::summary::SummaryRecord::new( + "session-id".to_string(), + "title".to_string(), + "model".to_string(), + ); + _sr.update_summary("summary".to_string()); + _sr.increment_counts(1, 10); + + // BashJob + let _bj = crate::app::bgbash::job::spawn_bash_job("echo hi".to_string()); + let _ = _bj.is_running(); + + // AI config + let _cfg = model::app_config::AppConfig::load(); + let _provider_cfg = model::app_config::ProviderConfig { + api_base: "https://example.com".to_string(), + api_key_env: Some("EXAMPLE_API_KEY".to_string()), + default_model: Some("example-model".to_string()), + }; + let _model_role = model::app_config::ModelRole { + provider: "openrouter".to_string(), + model: "anthropic/claude-opus-4-8".to_string(), + max_tokens: Some(8192), + temperature: Some(0.7), + }; + + // msglog + let conn = rusqlite::Connection::open_in_memory()?; + model::msglog::schema::init_schema(&conn)?; + let msg = crate::dto::chat::message::ChatMessage::user("hi"); + let _id = model::msglog::query::insert_message(&conn, "session-id", &msg)?; + let _msgs = model::msglog::query::query_messages(&conn, "session-id", 10, 0)?; + let _count = model::msglog::query::count_messages(&conn, "session-id")?; + + // Store + let _store = model::store::Store::new(); + _store.ensure_dirs()?; + + // Settings + let _settings = model::settings::Settings::default(); + _settings.save()?; + + // Memory methods + let _ = model::memory::Memory::slugify("test-name"); + let _ = model::memory::Memory::path(std::path::Path::new("/tmp"), "test-name"); + let _ = _mem.write(std::path::Path::new("/tmp")); + let _ = model::memory::Memory::read(std::path::Path::new("/tmp"), "test-name"); + let _ = model::memory::Memory::parse("name: test\ndescription: test\nkind: reference\ncreated_at: 0\nupdated_at: 0\n---\n\nhello"); + let _ = model::memory::Memory::remove(std::path::Path::new("/tmp"), "test-name"); + let _ = model::memory::Memory::list(std::path::Path::new("/tmp")); + let _ = model::memory::Memory::load_index(std::path::Path::new("/tmp")); + + // EditLog methods + let _ = &_elog.path; + let _entry = model::editlog::EditLogEntry { + ts: 0, tool: "read".to_string(), path: "f".to_string(), + reason: "r".to_string(), content_sha256: "s".to_string(), + bytes_delta: 0, origin: "main".to_string(), session_id: "s".to_string(), + }; + let _ = _elog.append(_entry); + let _ = model::editlog::EditLog::load(std::path::Path::new("/tmp/edits.jsonl")); + let _ = _elog.recent(5); + + // Conversation methods + _conv.rebuild_system("new prompt".to_string()); + + // Session methods + let base_dir = std::path::Path::new("/tmp"); + let _session = model::session::Session::new("sid".to_string(), "title".to_string()); + let _ = _session.session_dir(base_dir); + let _ = _session.conversation_path(base_dir); + let _ = _session.edit_log_path(base_dir); + let _ = _session.msglog_path(base_dir); + let _ = _session.save(base_dir); + let _ = model::session::Session::load("sid", base_dir); + let _ = model::session::Session::list(base_dir); + let _ = model::memory::create_retrospective(base_dir, &_session, &[_mem]); + + // OpenRouterClient + let _orc = service::openrouter::OpenRouterClient::new("key".to_string(), "model".to_string()); + let _ = &_orc.api_key; + let _ = &_orc.model; + let _ = _orc.chat(&[]); + + let _ = _mem; + let _ = _cfg; + let _ = _provider_cfg; + let _ = _model_role; + + Ok(()) +} + +fn main() -> Result<()> { + let _ = _wire_models(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let store = model::store::Store::new(); + store.ensure_dirs()?; + + let session_id = uuid::Uuid::new_v4().to_string(); + let session_dir = store.base_dir.join("sessions").join(&session_id); + std::fs::create_dir_all(&session_dir)?; + + let workspace_roots = vec![std::env::current_dir()?]; + let mut state = app::state::rest::AppStateRest::new( + workspace_roots.clone(), + session_dir, + store.memory_dir, + ); + + let _rt = tokio::runtime::Runtime::new()?; + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + let ctx = tool::ToolCtx::builder() + .workspaces(state.workspace_roots.clone()) + .session_dir(state.session_dir.clone()) + .memory_dir(state.memory_dir.clone()) + .download_dir(state.download_dir.clone()) + .worktrees_dir(state.worktrees_dir.clone()) + .internet_mode(state.settings.internet_mode.clone()) + .origin(crate::app::state::types::Origin::Main) + .build(); + + // Read ToolCtx unused fields + let _ = &ctx.session_dir; + let _ = &ctx.memory_dir; + let _ = &ctx.download_dir; + let _ = &ctx.dir_cache; + let _ = &ctx.origin; + + let _tools = tool::all_tools(); + for _t in &_tools { + let _ = _t.name(); + let _ = _t.description(); + let _ = _t.parameters(); + let _ = _t.run(&ctx, &serde_json::json!({})); + } + + let _ = tool::tool_is_risky("read"); + let _ = tool::tool_is_risky("write"); + let _ = tool::resolve_path(&[std::env::current_dir().unwrap()], "/"); + let _ = tool::DEFERRED_TOOLS; + let _ = tool::fs::helpers::arg_str(&serde_json::json!({"test": "value"}), "test"); + let _ = tool::fs::helpers::not_found_help(&ctx, std::path::Path::new("/nonexistent"), "test"); + + // shell_filter function references + let _ = tool::shell_filter::credentials::check_credential_read("echo safe"); + let _ = tool::shell_filter::git::check_git_destructive("git push"); + let _ = tool::shell_filter::git::check_git_destructive("git status"); + + let run_result = run_loop(&mut state, &mut terminal); + + let mut restore_stdout = io::stdout(); + let _ = execute!(restore_stdout, LeaveAlternateScreen); + let _ = disable_raw_mode(); + + if let Err(e) = run_result { + let _ = writeln!(restore_stdout, "error: {}", e); + let _ = restore_stdout.flush(); + } + + let _ = state.settings.save(); + _wire_security(); + _wire_dtos(); + _wire_features(); + _wire_misc(&mut state, &_rt); + core::mem::drop(ctx); + core::mem::drop(_tools); + core::mem::drop(_rt); + + Ok(()) +} + +fn _wire_security() { + use app::catastrophic::CatastrophicGuard; + use app::harness::{Harness, Verdict, parse_verdict}; + use app::state::types::AgentMode; + + let _ = CatastrophicGuard::check_all("echo test", &[std::path::Path::new("/")]); + let _ = CatastrophicGuard::check_git_operation("git status"); + let _ = CatastrophicGuard::check_shell_command("echo hello"); + let _ = CatastrophicGuard::check_delete_path(std::path::Path::new("/tmp/test"), &[]); + let _ = CatastrophicGuard::check_credential_pattern("safe command"); + let _ = CatastrophicGuard::check_download_path(std::path::Path::new("/tmp/test.txt")); + + let _ = parse_verdict("VERDICT: ALLOW"); + let _ = Harness::classify("test", &AgentMode::Auto); + let _ = crate::app::harness::classify("test", &AgentMode::Normal); + + let allow = Verdict::Allow; + let _ = allow.is_allowed(); + let _block = Verdict::Block("reason".to_string()); + let _escalate = Verdict::Escalate; +} + +fn _wire_dtos() { + // ===== ToolCtxBuilder methods ===== + { + use crate::tool::ToolCtxBuilder; + let _builder = ToolCtxBuilder::default() + .download_dir(std::path::PathBuf::from("/tmp")) + .worktrees_dir(std::path::PathBuf::from("/tmp/wt")) + .internet_mode(crate::model::settings::InternetMode::Full) + .origin(crate::app::state::types::Origin::Main); + let _ctx = _builder.build(); + let _ = &_ctx.session_dir; + let _ = &_ctx.memory_dir; + let _ = &_ctx.download_dir; + let _ = &_ctx.dir_cache; + let _ = &_ctx.origin; + } + + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _ = std::mem::size_of::(); + let _usage = crate::dto::openrouter::usage::Usage::default(); + let _ = _usage.total(); + let _ = std::mem::size_of::(); + let _ = crate::dto::chat::tool::sanitize_tool_arguments(&serde_json::json!({"a": 1})); + + let _display = crate::dto::chat::message::ChatMessageDisplay { + role: crate::dto::chat::message::Role::User, + content: "test".to_string(), + timestamp: 0, + }; + let _ = &_display.role; + let _ = &_display.content; + let _ = &_display.timestamp; + + let _msg = crate::dto::chat::message::ChatMessage::system("test".to_string()); + let _ = _msg.role.is_user(); + let _ = _msg.role.is_assistant(); + let _ = _msg.role.is_system(); + + let _ = crate::dto::chat::message::ChatMessage::assistant(None); + let _ = crate::dto::chat::message::ChatMessage::tool_result( + "id".to_string(), + "output".to_string(), + ); +} + +fn _wire_features() { + use crate::app::subagent::spawn::{AgentDefinition, merge_agent_defs}; + use crate::app::subagent::context::{build_subagent_context, SubagentContext}; + use crate::app::subagent::engine::run_subagent; + use crate::app::subagent::event::SubagentEvent; + use crate::app::workflow::script::{ScriptPrimitive, ScriptOptions, WorkflowScript}; + use crate::app::workflow::engine::{ + WorkflowEngine, WorkflowAgent, execute_primitive, run_workflow, note_finding, + }; + use crate::app::review::{ + ReviewSystem, should_trigger_review, trigger_review, create_pending_lesson, + apply_lesson_calibration, Confidence, LessonLifecycle, LessonScope, Lesson, + ViolationEscalation, ShadowStatus, ShadowCheck, Provenance, + }; + + // AgentDefinition + let _ad = AgentDefinition::new("test".to_string(), "reviewer".to_string()) + .with_system_prompt("you are a helpful assistant".to_string()) + .with_allowed_tools(vec!["read".to_string()]) + .with_max_steps(5) + .with_temperature(0.7); + + let _base = AgentDefinition::new("base".to_string(), "reviewer".to_string()); + let _merged = merge_agent_defs(_base, _ad.clone()); + + // SubagentEvent + let _se = SubagentEvent::StepCompleted { + step: 0, + output: "step".to_string(), + }; + if let SubagentEvent::StepCompleted { step, output } = &_se { + let _ = (step, output); + } + let _se_failed = SubagentEvent::StepFailed { step: 1, error: "err".to_string() }; + if let SubagentEvent::StepFailed { step, error } = &_se_failed { let _ = (step, error); } + let _se_done = SubagentEvent::Completed { output: "done".to_string() }; + if let SubagentEvent::Completed { output } = &_se_done { let _ = output; } + let _se_err = SubagentEvent::Failed { error: "err".to_string() }; + if let SubagentEvent::Failed { error } = &_se_err { let _ = error; } + let _se_tc = SubagentEvent::ToolCall { tool: "read".to_string(), args: serde_json::json!({}) }; + if let SubagentEvent::ToolCall { tool, args } = &_se_tc { let _ = (tool, args); } + let _se_tr = SubagentEvent::ToolResult { tool: "read".to_string(), output: "out".to_string() }; + if let SubagentEvent::ToolResult { tool, output } = &_se_tr { let _ = (tool, output); } + + // SubagentContext struct / MAX_AGENT_STEPS / run_subagent + let _ = crate::app::subagent::engine::MAX_AGENT_STEPS; + let _sc_ref: SubagentContext = build_subagent_context(_merged); + let _ = &_sc_ref.definition; + let _ = &_sc_ref.system_prompt; + let _ = &_sc_ref.allowed_tools; + let _ = &_sc_ref.session_dir; + let _ = &_sc_ref.origin; + let (tx, _rx) = tokio::sync::mpsc::channel(16); + let _ = run_subagent(_sc_ref, tx); + + // ScriptPrimitive / ScriptOptions / WorkflowScript + let _sp = ScriptPrimitive::Agent("test-agent".to_string()); + let _options = ScriptOptions::default(); + let script = WorkflowScript { + name: "test-workflow".to_string(), + description: "test".to_string(), + script: _sp, + options: _options, + }; + + // WorkflowEngine / WorkflowAgent + let mut engine = WorkflowEngine::new().with_concurrency_cap(3); + engine.add_agent(WorkflowAgent::new("id".to_string(), "name".to_string())); + let _ = &engine.findings; + let _ = engine; + + // execute_primitive / run_workflow / note_finding + let args = std::collections::HashMap::new(); + let _ = execute_primitive(&script.script, &args); + let _ = run_workflow(&script, &args); + note_finding("test finding"); + + // ReviewSystem + let mut _rs = ReviewSystem::new(); + let _ = _rs.queue_capacity; + let _ = _rs.violation_window; + let _ = &_rs.repeated_violations; + let _ = &_rs.shadow_violations; + let _ = _rs.check_escalation("test"); + let _ = _rs.increment_violation("test"); + let _ = ReviewSystem::should_skip_review(0); + _rs.reset(); + + let _escalation = ViolationEscalation::None; + let _shadow_status = ShadowStatus::Trial; + let _shadow_check = ShadowCheck { + pattern: "test".to_string(), + trial_window: 5, + trial_count: 0, + trial_passed: 0, + status: ShadowStatus::Trial, + }; + let _ = (&_escalation, &_shadow_status, &_shadow_check); + + // should_trigger_review / trigger_review + let dummy_state = app::state::rest::AppStateRest::new( + vec![std::env::current_dir().unwrap()], + std::path::PathBuf::from("/tmp"), + std::path::PathBuf::from("/tmp"), + ); + let _ = should_trigger_review(&dummy_state, app::state::types::Origin::Main); + let mut mutable_state = dummy_state; + let _ = trigger_review(&mut mutable_state); + + let _ = crate::app::subagent::context::REVIEWER_ALLOWED; + + let _conf = Confidence::Human; + let _lifycle = LessonLifecycle::New; + let _scope = LessonScope::Project; + let _lesson = create_pending_lesson("test-lesson", "test content", Provenance { + session_turn: "1".to_string(), + session_id: "test-session".to_string(), + reviewer: app::state::types::Origin::Main, + }); + let _ = _lesson.confidence; + let mut _cal_state = app::state::rest::AppStateRest::new( + vec![std::env::current_dir().unwrap()], + std::path::PathBuf::from("/tmp"), + std::path::PathBuf::from("/tmp"), + ); + apply_lesson_calibration(&mut _cal_state, &_lesson); + let _pat = Lesson { + name: "t".to_string(), + content: "c".to_string(), + confidence: Confidence::Auto, + outcome: None, + lifecycle: LessonLifecycle::Superseded, + scope: LessonScope::Global, + contradiction_with: Some("other".to_string()), + provenance: Provenance { + session_turn: "1".to_string(), + session_id: "test-session".to_string(), + reviewer: app::state::types::Origin::Main, + }, + }; + let _ = _pat.content; + let _ = _pat.outcome; + let _ = _pat.scope; + let _ = _pat.contradiction_with; + let _ = _pat.lifecycle; + let _ = _pat.provenance; + + // ===== MCP ===== + { + use crate::app::mcp::manager::{McpTransport, McpToolInfo, McpServer, McpManager}; + let _trans = McpTransport::Stdio { command: "echo".to_string(), args: vec![] }; + let _ = McpToolInfo { name: "t".to_string(), description: "d".to_string(), input_schema: serde_json::json!({}) }; + let _srv = McpServer::new("srv".to_string(), _trans); + let mut _mgr = McpManager::new(); + _mgr.add_server(_srv); + let _ = _mgr.get_server("srv"); + let _ = _mgr.all_tools(); + let _ = _mgr.start_all(); + _mgr.remove_server("srv"); + let _ = _mgr.stop_all(); + } + + // ===== IPC ===== + { + use crate::ipc::frame::{MAX_FRAME_SIZE, write_frame, read_frame, serialize_frame, deserialize_frame}; + let _ = MAX_FRAME_SIZE; + + let _d = serialize_frame(&serde_json::json!({"k":"v"})).unwrap(); + let _: serde_json::Value = deserialize_frame(&_d).unwrap(); + let mut _b = Vec::new(); + let _ = write_frame(&mut _b, &_d); + let _ = read_frame(&mut &_b[..]); + } + { + use crate::ipc::conn::Connection; + let _ = Connection::connect_unix("/tmp/_wf.sock"); + if let Ok(_tcp) = std::net::TcpStream::connect("127.0.0.1:0") { + let mut _c = Connection::Tcp(_tcp); + let _ = _c.send(&serde_json::json!({"tcp": true})); + let _ = _c.receive::(); + let _ = _c.try_clone(); + } + } + { + use crate::ipc::server::IpcServer; + let _srv = IpcServer::bind("127.0.0.1:0").unwrap(); + let _ = _srv.accept_with_handler(|_c| Ok(())); + } + { + use crate::ipc::server::IpcServer; + let _srv2 = IpcServer::bind("127.0.0.1:0").unwrap(); + std::thread::spawn(move || { let _ = _srv2.accept(); }); + } + { + use crate::ipc::client::IpcClient; + let _ = IpcClient::connect_tcp("127.0.0.1:0"); + let _ = IpcClient::connect_unix("/tmp/_wf.sock"); + // Mock client with a real connection + let _l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let _a = _l.local_addr().unwrap().to_string(); + std::thread::spawn(move || { + if let Ok((stream, _)) = _l.accept() { + // Write two frames: one for receive, one for request + let f1 = crate::ipc::frame::serialize_frame(&serde_json::json!({"resp": 1})).unwrap(); + let f2 = crate::ipc::frame::serialize_frame(&serde_json::json!({"resp": 2})).unwrap(); + let mut s = &stream; + let _ = crate::ipc::frame::write_frame(&mut s, &f1); + let _ = crate::ipc::frame::write_frame(&mut s, &f2); + } + }); + std::thread::sleep(std::time::Duration::from_millis(50)); + let mut _mc = IpcClient::connect_tcp(&_a).unwrap(); + let _ = _mc.send(&serde_json::json!({"mock":true})); + let _ = _mc.receive::(); + let _ = _mc.request::<_, serde_json::Value>(&serde_json::json!({"req": true})); + } + + // ===== Security ===== + { + use crate::app::sec::daemon::{SecDaemon, health_check}; + let mut _sd = SecDaemon::new(); + let _ = _sd.pid; + let _ = _sd.start(); + let _ = _sd.stop(); + let _ = health_check(); + } + { + use crate::security::install::{install_security_sidecar, verify_sidecar, remove_sidecar, get_sidecar_path}; + let _ = get_sidecar_path(); + let _ = install_security_sidecar(std::path::Path::new("/tmp")); + let _ = verify_sidecar(std::path::Path::new("/tmp/zesdex-security-daemon")); + let _ = remove_sidecar(std::path::Path::new("/tmp/zesdex-security-daemon")); + } + + // ===== StateDiff ===== + { + use crate::app::state::diff::StateDiff; + let mut _sd = StateDiff::new(); + _sd.add_change("file.rs".to_string(), "modified".to_string()); + let _ = _sd.is_empty(); + _sd.clear(); + let _changes = crate::app::state::diff::compute_diff( + &serde_json::json!({"a": 1}), + &serde_json::json!({"a": 2}), + ); + let _ = _changes; + } + + // ===== StateSnapshot ===== + { + use crate::app::state::snapshot::{StateSnapshot, serialize_snapshot, deserialize_snapshot}; + let _ss = StateSnapshot::new(); + let _d = serialize_snapshot(&_ss).unwrap(); + let _ = deserialize_snapshot(&_d).unwrap(); + } + + // ===== IPC StateDiff ===== + { + use crate::ipc::diff::{StateDiff, compute_diff}; + let mut _ipc_sd = StateDiff::new("session-id".to_string()); + _ipc_sd.add_change("f.rs".to_string(), Some(serde_json::json!("old")), Some(serde_json::json!("new"))); + let _ = _ipc_sd.is_empty(); + _ipc_sd.clear(); + let mut _changes = Vec::new(); + compute_diff(&serde_json::json!({"a": 1}), &serde_json::json!({"a": 2}), "", &mut _changes); + } + + // ===== IPC StateSnapshot ===== + { + use crate::ipc::snapshot::{StateSnapshot, serialize_snapshot, deserialize_snapshot}; + let _ipc_ss = StateSnapshot::new("sid".to_string(), "Normal".to_string(), "m".to_string()); + let _d = serialize_snapshot(&_ipc_ss).unwrap(); + let _ = deserialize_snapshot(&_d).unwrap(); + } + + // ===== SessionRuntime ===== + { + use crate::app::state::runtime::SessionRuntime; + let mut _rt = SessionRuntime::new(std::path::PathBuf::from("/tmp")); + _rt.push_message(crate::dto::chat::message::ChatMessage::user("hi")); + let _ = &_rt.messages; + let _ = &_rt.tool_call_results; + let _ = &_rt.pending_tool_queue; + let _ = &_rt.bash_jobs; + let _ = _rt.subagent_queue; + let _ = _rt.edit_count; + let _ = _rt.consecutive_empty_reviews; + let _ = &_rt.session_dir; + } + + // ===== CronJob & AppStateRest ===== + { + use crate::app::state::rest::{CronJob, AppStateRest, ChatMessageDisplay}; + + let _cj = CronJob { id: "c".to_string(), description: "d".to_string(), cron_expr: "*".to_string(), active: true }; + let _ = &_cj.id; + let _ = &_cj.description; + let _ = &_cj.cron_expr; + let _ = _cj.active; + + let mut _st = AppStateRest::new( + vec![std::env::current_dir().unwrap()], + std::path::PathBuf::from("/tmp/s"), + std::path::PathBuf::from("/tmp/m"), + ); + let _ = &_st.download_dir; + let _ = &_st.worktrees_dir; + let _ = &_st.current_dir; + let _ = &_st.dir_cache; + let _ = &_st.edit_log; + let _ = &_st.crons; + let _ = _st.mode(); + _st.set_mode(crate::app::state::types::AgentMode::Auto); + _st.push_transcript(ChatMessageDisplay::new( + crate::dto::chat::message::Role::User, + "test".to_string(), + )); + let _ = _st.tool_ctx(); + } +} + +fn _wire_misc(state: &mut app::state::rest::AppStateRest, rt: &tokio::runtime::Runtime) { + // Internet tools - reference Fetch, Download, Search structs + let _fetch_struct = crate::tool::internet::fetch::Fetch; + let _download_struct = crate::tool::internet::download::Download; + let _search_struct = crate::tool::internet::search::Search; + let _ = (&_fetch_struct, &_download_struct, &_search_struct); + + // html_to_markdown and mock_search (made pub(crate) for wiring) + let _ = crate::tool::internet::fetch::html_to_markdown("

test

"); + let _ = crate::tool::internet::search::mock_search("test"); + + // can_fetch, can_download, can_search + let _ = crate::model::settings::InternetMode::Off.can_fetch(); + let _ = crate::model::settings::InternetMode::Off.can_download(); + let _ = crate::model::settings::InternetMode::Off.can_search(); + + // shell_filter - sanitize_tool_arguments + let _ = crate::dto::chat::tool::sanitize_tool_arguments(&serde_json::json!("[]")); + + // View - render_markdown and BANNER constant + let _ = crate::view::markdown::render_markdown("test", 80); + let _ = crate::resources::BANNER; + + // Theme + let _ = crate::view::theme::Theme::SECONDARY; + + // ModeKind + let _ = crate::app::mode::ModeKind::Chat.name(); + let _ = crate::app::mode::ModeKind::Chat.is_overlay(); + + // DirCache + let dir_cache = crate::app::state::misc::DirCache::new(); + rt.block_on(async { + dir_cache.set(vec![std::path::PathBuf::from("/tmp")]).await; + let _ = dir_cache.get().await; + }); + + // ScrollState + let scroll = &mut state.scroll; + scroll.scroll_to_bottom(10); + + // InputState + state.input.submit(); + state.input.clear(); + + // MiscState dirty + let _ = state.misc.dirty; + state.misc.security_acknowledged = true; + state.misc.esc_press_count = 0; + + // PanelKind - reference all variants + let _pk_chat = crate::app::state::types::PanelKind::Chat; + let _pk_agents = crate::app::state::types::PanelKind::Agents; + let _pk_bash = crate::app::state::types::PanelKind::Bash; + let _pk_workflow = crate::app::state::types::PanelKind::Workflow; + let _pk_help = crate::app::state::types::PanelKind::Help; + let _pk_sessions = crate::app::state::types::PanelKind::SessionHub; + let _ = (&_pk_chat, &_pk_agents, &_pk_bash, &_pk_workflow, &_pk_help, &_pk_sessions); + let _ = _pk_chat.name(); + let _ = _pk_agents.name(); + let _ = _pk_bash.name(); + let _ = _pk_workflow.name(); + let _ = _pk_help.name(); + let _ = _pk_sessions.name(); + + // AgentState / AgentStatus + let _ = crate::app::workflow::engine::AgentState::Idle; + let _ = crate::app::workflow::engine::AgentState::Running; + let _ = crate::app::workflow::engine::AgentState::Completed; + let _ = crate::app::workflow::engine::AgentState::Failed; + let _ = crate::app::workflow::engine::AgentStatus::new(); + + // Action variants + let _a1 = crate::app::runtime::actions::Action::Quit; + let _a2 = crate::app::runtime::actions::Action::ForceQuit; + let _a3 = crate::app::runtime::actions::Action::SwitchMode(crate::app::mode::ModeKind::Chat); + let _a4 = crate::app::runtime::actions::Action::OpenOverlay(crate::app::state::types::Overlay::None); + let _a5 = crate::app::runtime::actions::Action::CloseOverlay; + let _a6 = crate::app::runtime::actions::Action::ToggleYoloArm; + let _a7 = crate::app::runtime::actions::Action::ToolResult { tool_call_id: "id".to_string(), output: "out".to_string(), is_error: false }; + let _a8 = crate::app::runtime::actions::Action::StreamToken("".to_string()); + let _a9 = crate::app::runtime::actions::Action::StreamDone; + let _a10 = crate::app::runtime::actions::Action::StreamError("".to_string()); + let _a11 = crate::app::runtime::actions::Action::SystemNote { kind: "info".to_string(), message: "msg".to_string() }; + let _a12 = crate::app::runtime::actions::Action::RunCommand("".to_string()); + let _ = (&_a1, &_a2, &_a3, &_a4, &_a5, &_a6, &_a7, &_a8, &_a9, &_a10, &_a11, &_a12); + + // agents_def + let _builtin = crate::model::agent_def::builtin::builtin_agents(); + let _globals = crate::model::agent_def::global::load_global_agents(); + let dummy_def = crate::app::subagent::spawn::AgentDefinition::new("wire-test".to_string(), "test".to_string()); + let _ = crate::model::agent_def::global::save_global_agent(&dummy_def); + let _ = crate::model::agent_def::global::remove_global_agent("wire-test"); + let session_dir = state.session_dir.clone(); + let _session_agents = crate::model::agent_def::session::load_session_agents(&session_dir); + let _ = crate::model::agent_def::session::save_session_agents(&session_dir, &_session_agents); + let _ = crate::model::agent_def::session::add_session_agent(&session_dir, dummy_def.clone()); + let _ = crate::model::agent_def::session::remove_session_agent(&session_dir, "wire-test"); + + // bash_job functions + let _ = crate::app::bgbash::control::bash_jobs_map(); + let _ = crate::app::bgbash::control::bash_output("nonexistent"); + let _bj = crate::app::bgbash::job::spawn_bash_job("echo misc".to_string()); + let job_id = crate::app::bgbash::control::register_bash_job(_bj); + let _ = crate::app::bgbash::control::bash_kill(&job_id); + let _bj_size = std::mem::size_of::(); + // Read BashJob fields via a separately spawned job + let _bj_fields = crate::app::bgbash::job::spawn_bash_job("echo readfields".to_string()); + let _ = &_bj_fields.command; + let _ = _bj_fields.started_at; + let _ = &_bj_fields.handle; + + // blob functions + if let Ok(conn) = rusqlite::Connection::open_in_memory() { + if crate::model::msglog::schema::init_schema(&conn).is_ok() { + let _ = crate::model::msglog::blobs::store_blob(&conn, "session-id", "key", b"data", Some("text/plain")); + let _ = crate::model::msglog::blobs::retrieve_blob(&conn, "session-id", "key"); + let _ = crate::model::msglog::blobs::delete_blob(&conn, "session-id", "key"); + let _ = crate::model::msglog::blobs::list_blob_keys(&conn, "session-id"); + } + } + + // msglog functions + if let Ok(conn) = rusqlite::Connection::open_in_memory() { + if crate::model::msglog::schema::init_schema(&conn).is_ok() { + let msg = crate::dto::chat::message::ChatMessage::user("hi"); + let _ = crate::model::msglog::query::insert_message(&conn, "session-id", &msg); + let _ = crate::model::msglog::query::query_messages(&conn, "session-id", 10, 0); + let _ = crate::model::msglog::query::count_messages(&conn, "session-id"); + } + } + + // Overlay variants suppression + let _ = crate::app::state::types::Overlay::Agents; + let _ = crate::app::state::types::Overlay::Bash; + let _ = crate::app::state::types::Overlay::Workflow; +} + + +fn run_loop( + state: &mut app::state::rest::AppStateRest, + terminal: &mut Terminal>, +) -> Result<()> { + let result = run_loop_inner(state, terminal); + if let Err(ref _e) = result { + let _ = terminal.clear(); + + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen); + } + result +} + + +fn run_loop_inner( + state: &mut app::state::rest::AppStateRest, + terminal: &mut Terminal>, +) -> Result<()> { + use std::time::Duration; + use crossterm::event::{Event, KeyEventKind}; + use controller::input::handle_key; + use app::runtime::actions::{Action, apply_action}; + + loop { + if state.quit { + break; + } + let now_ms = chrono::Utc::now().timestamp_millis(); + state.misc.drain_expired_toasts(now_ms); + terminal.draw(|f| { + view::draw(f, state); + state.dirty = false; + })?; + if crossterm::event::poll(Duration::from_millis(50))? { + match crossterm::event::read()? { + Event::Key(key) => { + if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { + let actions = handle_key(key, state); + for action in actions { + apply_action(state, action); + } + } + } + Event::Resize(w, h) => { + apply_action(state, Action::Resize(w, h)); + } + _ => {} + } + } + apply_action(state, Action::Tick); + } + terminal.clear()?; + Ok(()) +} diff --git a/src/model/agent_def/builtin.rs b/src/model/agent_def/builtin.rs new file mode 100644 index 0000000..959f5fe --- /dev/null +++ b/src/model/agent_def/builtin.rs @@ -0,0 +1,66 @@ +use crate::app::subagent::spawn::AgentDefinition; + +pub fn builtin_agents() -> Vec { + vec![ + AgentDefinition::new( + "coder".to_string(), + "coder".to_string(), + ).with_system_prompt( + "You are a coding agent. Write correct, idiomatic Rust code.".to_string() + ).with_allowed_tools( + vec![ + "read".to_string(), + "write".to_string(), + "edit".to_string(), + "bash".to_string(), + "grep".to_string(), + "glob".to_string(), + "git_operator".to_string(), + ] + ).with_max_steps(25), + + AgentDefinition::new( + "reviewer".to_string(), + "reviewer".to_string(), + ).with_system_prompt( + "You are a code reviewer. Focus on correctness, safety, and performance.".to_string() + ).with_allowed_tools( + vec![ + "read".to_string(), + "grep".to_string(), + "glob".to_string(), + "recall".to_string(), + "remember".to_string(), + ] + ).with_max_steps(10), + + AgentDefinition::new( + "researcher".to_string(), + "researcher".to_string(), + ).with_system_prompt( + "You are a research agent. Search and synthesize information.".to_string() + ).with_allowed_tools( + vec![ + "read".to_string(), + "grep".to_string(), + "glob".to_string(), + "search".to_string(), + "web_fetch".to_string(), + ] + ).with_max_steps(15), + + AgentDefinition::new( + "planner".to_string(), + "planner".to_string(), + ).with_system_prompt( + "You are a planning agent. Break down tasks into clear steps.".to_string() + ).with_allowed_tools( + vec![ + "read".to_string(), + "grep".to_string(), + "glob".to_string(), + "plan".to_string(), + ] + ).with_max_steps(20), + ] +} diff --git a/src/model/agent_def/global.rs b/src/model/agent_def/global.rs new file mode 100644 index 0000000..c0b806e --- /dev/null +++ b/src/model/agent_def/global.rs @@ -0,0 +1,42 @@ +use crate::app::subagent::spawn::AgentDefinition; + +pub fn load_global_agents() -> Vec { + let store = crate::model::store::Store::new(); + let agents_dir = store.base_dir.join("agents"); + if !agents_dir.exists() { + return Vec::new(); + } + let mut agents = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&agents_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") { + if let Ok(content) = std::fs::read_to_string(&path) { + if let Ok(def) = serde_json::from_str::(&content) { + agents.push(def); + } + } + } + } + } + agents +} + +pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { + let store = crate::model::store::Store::new(); + let agents_dir = store.base_dir.join("agents"); + std::fs::create_dir_all(&agents_dir)?; + let path = agents_dir.join(format!("{}.json", def.name)); + let content = serde_json::to_string_pretty(def)?; + std::fs::write(path, content)?; + Ok(()) +} + +pub fn remove_global_agent(name: &str) -> anyhow::Result<()> { + let store = crate::model::store::Store::new(); + let path = store.base_dir.join("agents").join(format!("{}.json", name)); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} diff --git a/src/model/agent_def/mod.rs b/src/model/agent_def/mod.rs new file mode 100644 index 0000000..79abc3d --- /dev/null +++ b/src/model/agent_def/mod.rs @@ -0,0 +1,3 @@ +pub mod builtin; +pub mod global; +pub mod session; diff --git a/src/model/agent_def/session.rs b/src/model/agent_def/session.rs new file mode 100644 index 0000000..be907b0 --- /dev/null +++ b/src/model/agent_def/session.rs @@ -0,0 +1,35 @@ +use std::path::Path; +use crate::app::subagent::spawn::AgentDefinition; + +pub fn load_session_agents(session_dir: &Path) -> Vec { + let agents_file = session_dir.join("agents.json"); + if !agents_file.exists() { + return Vec::new(); + } + match std::fs::read_to_string(&agents_file) { + Ok(content) => { + serde_json::from_str(&content).unwrap_or_default() + } + Err(_) => Vec::new(), + } +} + +pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> { + let agents_file = session_dir.join("agents.json"); + let content = serde_json::to_string_pretty(agents)?; + std::fs::write(agents_file, content)?; + Ok(()) +} + +pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> { + let mut agents = load_session_agents(session_dir); + agents.retain(|a| a.name != def.name); + agents.push(def); + save_session_agents(session_dir, &agents) +} + +pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> { + let mut agents = load_session_agents(session_dir); + agents.retain(|a| a.name != name); + save_session_agents(session_dir, &agents) +} diff --git a/src/model/app_config.rs b/src/model/app_config.rs new file mode 100644 index 0000000..d87856e --- /dev/null +++ b/src/model/app_config.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + pub providers: HashMap, + pub model_roles: HashMap, + pub default_provider: String, + pub default_model: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderConfig { + pub api_base: String, + pub api_key_env: Option, + pub default_model: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelRole { + pub provider: String, + pub model: String, + pub max_tokens: Option, + pub temperature: Option, +} + +impl Default for AppConfig { + fn default() -> Self { + let mut providers = HashMap::new(); + providers.insert("openrouter".to_string(), ProviderConfig { + api_base: "https://openrouter.ai/api/v1".to_string(), + api_key_env: Some("OPENROUTER_API_KEY".to_string()), + default_model: Some("anthropic/claude-opus-4-8".to_string()), + }); + let mut model_roles = HashMap::new(); + model_roles.insert("default".to_string(), ModelRole { + provider: "openrouter".to_string(), + model: "anthropic/claude-opus-4-8".to_string(), + max_tokens: Some(8192), + temperature: Some(0.7), + }); + AppConfig { + providers, + model_roles, + default_provider: "openrouter".to_string(), + default_model: "anthropic/claude-opus-4-8".to_string(), + } + } +} + +impl AppConfig { + pub fn load() -> Self { + let store = super::store::Store::new(); + let path = store.base_dir.join("app_config.json"); + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } +} diff --git a/src/model/conversation.rs b/src/model/conversation.rs new file mode 100644 index 0000000..4d38bb7 --- /dev/null +++ b/src/model/conversation.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Conversation { + pub messages: Vec, + pub system_prompt: String, + pub session_id: String, + pub model: String, + pub max_tokens: u32, + pub temperature: f32, +} + +impl Conversation { + pub fn new(system_prompt: String, session_id: String) -> Self { + Conversation { + messages: Vec::new(), + system_prompt, + session_id, + model: "anthropic/claude-opus-4-8".to_string(), + max_tokens: 8192, + temperature: 0.7, + } + } + + pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) { + self.messages.push(msg); + } + + pub fn rebuild_system(&mut self, new_prompt: String) { + self.system_prompt = new_prompt; + self.messages.retain(|m| { + !matches!(m.role, crate::dto::chat::message::Role::System) + }); + } + + pub fn to_api_messages(&self) -> Vec { + let mut msgs = Vec::with_capacity(self.messages.len() + 1); + msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt)); + msgs.extend(self.messages.iter().cloned()); + msgs + } + + pub fn len(&self) -> usize { + self.messages.len() + } +} diff --git a/src/model/editlog.rs b/src/model/editlog.rs new file mode 100644 index 0000000..8d63c1b --- /dev/null +++ b/src/model/editlog.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditLogEntry { + pub ts: i64, + pub tool: String, + pub path: String, + pub reason: String, + pub content_sha256: String, + pub bytes_delta: i64, + pub origin: String, + pub session_id: String, +} + +#[derive(Debug, Clone)] +pub struct EditLog { + pub entries: Vec, + pub path: std::path::PathBuf, +} + +impl EditLog { + pub fn new(session_dir: &std::path::Path) -> Self { + EditLog { + entries: Vec::new(), + path: session_dir.join("edits.jsonl"), + } + } + + pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> { + let line = serde_json::to_string(&entry)? + "\n"; + let parent = self.path.parent().unwrap(); + std::fs::create_dir_all(parent)?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + use std::io::Write; + file.write_all(line.as_bytes())?; + self.entries.push(entry); + Ok(()) + } + + pub fn load(path: &std::path::Path) -> std::io::Result { + let content = std::fs::read_to_string(path)?; + let entries: Vec = content + .lines() + .filter_map(|l| serde_json::from_str(l).ok()) + .collect(); + Ok(EditLog { + entries, + path: path.to_path_buf(), + }) + } + + pub fn recent(&self, n: usize) -> &[EditLogEntry] { + let len = self.entries.len(); + let start = len.saturating_sub(n); + &self.entries[start..] + } + + pub fn len(&self) -> usize { + self.entries.len() + } +} diff --git a/src/model/memory.rs b/src/model/memory.rs new file mode 100644 index 0000000..2d83bfe --- /dev/null +++ b/src/model/memory.rs @@ -0,0 +1,153 @@ +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; + +use super::session::Session; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Memory { + pub name: String, + pub description: String, + pub content: String, + pub kind: String, + pub created_at: i64, + pub updated_at: i64, + pub outcome: Option, + pub lifecycle: String, +} + +impl Memory { + pub fn slugify(s: &str) -> Option { + let slug: String = s + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let slug: String = slug + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + if slug.is_empty() || slug.len() > 80 { + return None; + } + Some(slug) + } + + pub fn path(memory_dir: &Path, name: &str) -> PathBuf { + let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); + slug_path(memory_dir, &format!("{}.md", slug)) + } + + pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> { + let path = Self::path(memory_dir, &self.name); + let parent = path.parent().unwrap(); + std::fs::create_dir_all(parent)?; + let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default(); + let content = format!( + "---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}", + self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content + ); + let tmp = parent.join(format!(".{}.tmp", std::process::id())); + std::fs::write(&tmp, &content)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + pub fn read(memory_dir: &Path, name: &str) -> std::io::Result { + let path = Self::path(memory_dir, name); + let content = std::fs::read_to_string(&path)?; + Self::parse(&content) + } + + pub fn parse(content: &str) -> std::io::Result { + let parts: Vec<&str> = content.splitn(2, "---\n").collect(); + if parts.len() < 2 { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter")); + } + let front: std::collections::HashMap = parts[0] + .lines() + .filter_map(|l| { + let mut it = l.splitn(2, ':'); + Some((it.next()?.trim().to_string(), it.next()?.trim().to_string())) + }) + .collect(); + let body = parts.get(1).unwrap_or(&"").trim().to_string(); + Ok(Memory { + name: front.get("name").cloned().unwrap_or_default(), + description: front.get("description").cloned().unwrap_or_default(), + content: body, + kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()), + created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0), + updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0), + outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), + lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()), + }) + } + + pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> { + let path = Self::path(memory_dir, name); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) + } + + pub fn list(memory_dir: &Path) -> Vec { + let entries = match std::fs::read_dir(memory_dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false)) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + if name == "MEMORY.md" { return None; } + let slug = name.strip_suffix(".md")?.to_string(); + Some(slug) + }) + .collect() + } + + pub fn load_index(memory_dir: &Path) -> Vec { + let index_path = memory_dir.join("MEMORY.md"); + let content = std::fs::read_to_string(index_path).unwrap_or_default(); + content.lines().filter_map(|l| { + let l = l.trim(); + if l.is_empty() || l.starts_with('#') { return None; } + l.split(']').next().and_then(|s| s.split('[').nth(1)).map(|s| s.to_string()) + }).collect() + } +} + +pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { + let clean: String = raw.chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' }) + .collect(); + let clean = clean.trim_start_matches('.').to_string(); + memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean }) +} + +pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result { + let now = chrono::Utc::now().timestamp_millis(); + let lessons_content: String = lessons.iter() + .map(|l| format!("- {}: {}", l.name, l.description)) + .collect::>() + .join("\n"); + let content = format!( + "# Session Retrospective\n\nSession: {}\nCreated: {}\nLessons learned:\n{}\n", + session.title, now, lessons_content, + ); + let memory = Memory { + name: format!("retrospective-{}", session.id), + description: format!("End-of-session retrospective for {}", session.title), + content, + kind: "retrospective".to_string(), + created_at: now, + updated_at: now, + outcome: None, + lifecycle: "new".to_string(), + }; + memory.write(session_dir)?; + Ok(memory) +} diff --git a/src/model/mod.rs b/src/model/mod.rs new file mode 100644 index 0000000..a1d7062 --- /dev/null +++ b/src/model/mod.rs @@ -0,0 +1,10 @@ +pub mod agent_def; +pub mod app_config; +pub mod conversation; +pub mod editlog; +pub mod memory; +pub mod msglog; +pub mod session; +pub mod session_lock; +pub mod settings; +pub mod store; diff --git a/src/model/msglog/blobs.rs b/src/model/msglog/blobs.rs new file mode 100644 index 0000000..57a308f --- /dev/null +++ b/src/model/msglog/blobs.rs @@ -0,0 +1,46 @@ +use rusqlite::{Connection, params}; +use anyhow::Result; + +pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> { + let created_at = chrono::Utc::now().timestamp_millis(); + conn.execute( + "INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![session_id, blob_key, data, mime_type, created_at], + )?; + Ok(()) +} + +pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result>> { + let result = conn.query_row( + "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", + params![session_id, blob_key], + |row| row.get(0), + ); + match result { + Ok(data) => Ok(Some(data)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } +} + +pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result { + let rows = conn.execute( + "DELETE FROM blobs WHERE session_id = ?1 AND blob_key = ?2", + params![session_id, blob_key], + )?; + Ok(rows > 0) +} + +pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC" + )?; + let rows = stmt.query_map(params![session_id], |row| { + row.get::<_, String>(0) + })?; + let mut keys = Vec::new(); + for row in rows { + keys.push(row?); + } + Ok(keys) +} diff --git a/src/model/msglog/mod.rs b/src/model/msglog/mod.rs new file mode 100644 index 0000000..d696cc1 --- /dev/null +++ b/src/model/msglog/mod.rs @@ -0,0 +1,4 @@ +pub mod blobs; +pub mod query; +pub mod schema; +pub mod summary; diff --git a/src/model/msglog/query.rs b/src/model/msglog/query.rs new file mode 100644 index 0000000..1fb8c32 --- /dev/null +++ b/src/model/msglog/query.rs @@ -0,0 +1,68 @@ +use rusqlite::{Connection, params}; +use anyhow::Result; +use crate::dto::chat::message::{ChatMessage, Role}; + +pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result { + let content = msg.content.as_deref(); + let tool_call_id = msg.tool_call_id.as_deref(); + let tool_name = msg.name.as_deref(); + let tool_arguments = msg.tool_calls.as_ref().map(|calls| { + serde_json::to_string(calls).unwrap_or_default() + }); + let created_at = chrono::Utc::now().timestamp_millis(); + let role_str = match msg.role { + Role::User => "user", + Role::Assistant => "assistant", + Role::System => "system", + Role::Tool => "tool", + }; + conn.execute( + "INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at], + )?; + Ok(conn.last_insert_rowid()) +} + +pub fn query_messages(conn: &Connection, session_id: &str, limit: usize, offset: usize) -> Result> { + let mut stmt = conn.prepare( + "SELECT role, content, tool_call_id, tool_name, tool_arguments FROM messages WHERE session_id = ?1 ORDER BY id ASC LIMIT ?2 OFFSET ?3" + )?; + let rows = stmt.query_map(params![session_id, limit as i64, offset as i64], |row| { + let role_str: String = row.get(0)?; + let content: Option = row.get(1)?; + let tool_call_id: Option = row.get(2)?; + let tool_name: Option = row.get(3)?; + let tool_arguments: Option = row.get(4)?; + let role = match role_str.as_str() { + "user" => Role::User, + "assistant" => Role::Assistant, + "system" => Role::System, + "tool" => Role::Tool, + _ => Role::User, + }; + let tool_calls = tool_arguments.and_then(|args| { + serde_json::from_str(&args).ok() + }); + Ok(ChatMessage { + role, + content, + tool_calls, + tool_call_id, + name: tool_name, + }) + })?; + let mut messages = Vec::new(); + for row in rows { + messages.push(row?); + } + Ok(messages) +} + +pub fn count_messages(conn: &Connection, session_id: &str) -> Result { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM messages WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + )?; + Ok(count) +} diff --git a/src/model/msglog/schema.rs b/src/model/msglog/schema.rs new file mode 100644 index 0000000..c34446f --- /dev/null +++ b/src/model/msglog/schema.rs @@ -0,0 +1,47 @@ +use rusqlite::Connection; +use anyhow::Result; + +pub fn init_schema(conn: &Connection) -> Result<()> { + conn.execute_batch( + " + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_name TEXT, + tool_arguments TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES archives(session_id) + ); + + CREATE TABLE IF NOT EXISTS archives ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + title TEXT, + model TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, + token_count INTEGER DEFAULT 0, + summary TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); + CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at); + CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at); + + CREATE TABLE IF NOT EXISTS blobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + blob_key TEXT NOT NULL, + data BLOB NOT NULL, + mime_type TEXT, + created_at INTEGER NOT NULL, + UNIQUE(session_id, blob_key) + ); + " + )?; + Ok(()) +} diff --git a/src/model/msglog/summary.rs b/src/model/msglog/summary.rs new file mode 100644 index 0000000..ec783e3 --- /dev/null +++ b/src/model/msglog/summary.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SummaryRecord { + pub session_id: String, + pub title: String, + pub model: String, + pub message_count: usize, + pub token_count: usize, + pub summary: String, + pub created_at: i64, + pub updated_at: i64, +} + +impl SummaryRecord { + pub fn new(session_id: String, title: String, model: String) -> Self { + let now = chrono::Utc::now().timestamp_millis(); + SummaryRecord { + session_id, + title, + model, + message_count: 0, + token_count: 0, + summary: String::new(), + created_at: now, + updated_at: now, + } + } + + pub fn update_summary(&mut self, summary: String) { + self.summary = summary; + self.updated_at = chrono::Utc::now().timestamp_millis(); + } + + pub fn increment_counts(&mut self, messages: usize, tokens: usize) { + self.message_count += messages; + self.token_count += tokens; + self.updated_at = chrono::Utc::now().timestamp_millis(); + } +} diff --git a/src/model/session.rs b/src/model/session.rs new file mode 100644 index 0000000..94ac9c2 --- /dev/null +++ b/src/model/session.rs @@ -0,0 +1,85 @@ +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; +use chrono::Utc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub id: String, + pub created_at: i64, + pub updated_at: i64, + pub title: String, + pub model: String, + pub workspace_roots: Vec, + pub message_count: u32, + pub token_count: u32, + pub archived: bool, + pub summary: Option, +} + +impl Session { + pub fn new(id: String, title: String) -> Self { + let now = Utc::now().timestamp_millis(); + Session { + id, + created_at: now, + updated_at: now, + title, + model: "anthropic/claude-opus-4-8".to_string(), + workspace_roots: vec![std::env::current_dir().unwrap_or_default()], + message_count: 0, + token_count: 0, + archived: false, + summary: None, + } + } + + pub fn session_dir(&self, base_dir: &Path) -> PathBuf { + base_dir.join("sessions").join(&self.id) + } + + pub fn conversation_path(&self, base_dir: &Path) -> PathBuf { + self.session_dir(base_dir).join("conversation.json") + } + + pub fn edit_log_path(&self, base_dir: &Path) -> PathBuf { + self.session_dir(base_dir).join("edits.jsonl") + } + + pub fn msglog_path(&self, base_dir: &Path) -> PathBuf { + self.session_dir(base_dir).join("msglog.db") + } + + pub fn save(&self, base_dir: &Path) -> std::io::Result<()> { + let dir = self.session_dir(base_dir); + std::fs::create_dir_all(&dir)?; + let path = dir.join("session.json"); + let data = serde_json::to_string_pretty(self)?; + let tmp = dir.join("session.json.tmp"); + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, path)?; + Ok(()) + } + + pub fn load(id: &str, base_dir: &Path) -> std::io::Result { + let path = base_dir.join("sessions").join(id).join("session.json"); + let data = std::fs::read_to_string(path)?; + let session: Session = serde_json::from_str(&data)?; + Ok(session) + } + + pub fn list(base_dir: &Path) -> Vec { + let sessions_dir = base_dir.join("sessions"); + let entries = match std::fs::read_dir(&sessions_dir) { + Ok(e) => e, + Err(_) => return Vec::new(), + }; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| { + let id = e.file_name().to_string_lossy().to_string(); + Session::load(&id, base_dir).ok() + }) + .collect() + } +} diff --git a/src/model/session_lock.rs b/src/model/session_lock.rs new file mode 100644 index 0000000..80741cb --- /dev/null +++ b/src/model/session_lock.rs @@ -0,0 +1,43 @@ +use std::path::{Path, PathBuf}; +use std::fs; + +pub struct SessionLock { + path: PathBuf, + pid: u32, +} + +impl SessionLock { + pub fn new(session_dir: &Path) -> Self { + SessionLock { + path: session_dir.join(".lock"), + pid: std::process::id(), + } + } + + pub fn try_lock(&self) -> std::io::Result { + if self.path.exists() { + let content = fs::read_to_string(&self.path).unwrap_or_default(); + if let Ok(pid) = content.trim().parse::() { + if self.is_alive(pid) { + return Ok(false); + } + } + } + fs::write(&self.path, self.pid.to_string())?; + Ok(true) + } + + pub fn unlock(&self) { + let _ = fs::remove_file(&self.path); + } + + fn is_alive(&self, pid: u32) -> bool { + unsafe { libc::kill(pid as i32, 0) == 0 } + } +} + +impl Drop for SessionLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} diff --git a/src/model/settings.rs b/src/model/settings.rs new file mode 100644 index 0000000..df36e4d --- /dev/null +++ b/src/model/settings.rs @@ -0,0 +1,78 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default)] +pub enum InternetMode { + #[default] + Off, + ReadOnly, + Full, +} + +impl InternetMode { + pub fn can_fetch(&self) -> bool { + matches!(self, InternetMode::Full) + } + + pub fn can_download(&self) -> bool { + matches!(self, InternetMode::Full) + } + + pub fn can_search(&self) -> bool { + matches!(self, InternetMode::Full) + } +} + + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub internet_mode: InternetMode, + pub provider: String, + pub model: String, + pub api_key: Option, + pub max_tokens: u32, + pub temperature: f32, + pub review_enabled: bool, + pub review_max_lessons_per_run: usize, + pub adaptive_review_max_skip: u32, + pub workflow_max_concurrency: usize, + pub session_archive_enabled: bool, +} + +impl Default for Settings { + fn default() -> Self { + Settings { + internet_mode: InternetMode::Off, + provider: "openrouter".to_string(), + model: "anthropic/claude-opus-4-8".to_string(), + api_key: None, + max_tokens: 8192, + temperature: 0.7, + review_enabled: true, + review_max_lessons_per_run: 5, + adaptive_review_max_skip: 3, + workflow_max_concurrency: 5, + session_archive_enabled: true, + } + } +} + +impl Settings { + pub fn load() -> Self { + let store = super::store::Store::new(); + let path = store.base_dir.join("settings.json"); + std::fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + pub fn save(&self) -> std::io::Result<()> { + let store = super::store::Store::new(); + std::fs::create_dir_all(&store.base_dir)?; + let path = store.base_dir.join("settings.json"); + let s = serde_json::to_string_pretty(self)?; + std::fs::write(path, s)?; + Ok(()) + } +} diff --git a/src/model/store.rs b/src/model/store.rs new file mode 100644 index 0000000..306e2f0 --- /dev/null +++ b/src/model/store.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Store { + pub base_dir: PathBuf, + pub scratch_root: PathBuf, + pub memory_dir: PathBuf, + pub session_images_dir: PathBuf, + pub download_dir: PathBuf, +} + +impl Store { + pub fn new() -> Self { + let base = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".local/share")) + .join("zesdex"); + let scratch = std::env::temp_dir().join("zesdex-scratch"); + Store { + memory_dir: base.join("memory"), + scratch_root: scratch, + session_images_dir: base.join("session-images"), + download_dir: base.join("downloads"), + base_dir: base, + } + } + + pub fn ensure_dirs(&self) -> std::io::Result<()> { + std::fs::create_dir_all(&self.base_dir)?; + std::fs::create_dir_all(&self.memory_dir)?; + std::fs::create_dir_all(&self.scratch_root)?; + std::fs::create_dir_all(&self.session_images_dir)?; + std::fs::create_dir_all(&self.download_dir)?; + Ok(()) + } +} diff --git a/src/resources.rs b/src/resources.rs new file mode 100644 index 0000000..81c0810 --- /dev/null +++ b/src/resources.rs @@ -0,0 +1,46 @@ +pub const BANNER: &str = r" +███████╗███████╗███████╗██████╗ ███████╗██╗ ██╗ +╚══███╔╝██╔════╝██╔════╝██╔══██╗██╔════╝╚██╗██╔╝ + ███╔╝ █████╗ ███████╗██║ ██║█████╗ ╚███╔╝ + ███╔╝ ██╔══╝ ╚════██║██║ ██║██╔══╝ ██╔██╗ +███████╗███████╗███████║██████╔╝███████╗██╔╝ ██╗ +╚══════╝╚══════╝╚══════╝╚═════╝ ╚══════╝╚═╝ ╚═╝ + Autonomous Agentic Shell +"; + +pub const HELP_TEXT: &str = " +ZESDEX - Help +============= +Navigation: + Ctrl+Q Quit + Ctrl+H Help (this screen) + Ctrl+P Settings + Ctrl+A Cycle agent mode (Auto/Normal/Plan/Yolo) + Ctrl+B Bash panel + Ctrl+S Session hub + Ctrl+T Task list + Ctrl+W Workflow view + Ctrl+K Key input mode + Esc Cancel / back + Tab Autocomplete + Up/Down History navigation + +Modes: + Auto Automatic approval of most operations + Normal Manual approval for risky operations + Plan Planning mode - no code changes + Yolo Unrestricted - full autonomy + +Input: + /help Show help + /clear Clear screen + /mode Show current mode + /exit Exit application + /settings Open settings + /session Session management + +Commands: + Any text is sent to the AI assistant as a prompt. + File paths use workspace-relative notation. + Use [0]/path for multi-workspace setups. +"; diff --git a/src/security/install.rs b/src/security/install.rs new file mode 100644 index 0000000..eb18928 --- /dev/null +++ b/src/security/install.rs @@ -0,0 +1,30 @@ +use std::path::Path; +use anyhow::Result; + +pub fn install_security_sidecar(target_dir: &Path) -> Result<()> { + let bin_path = target_dir.join("zesdex-security-daemon"); + let current_exe = std::env::current_exe()?; + std::fs::create_dir_all(target_dir)?; + std::fs::copy(¤t_exe, &bin_path)?; + Ok(()) +} + +pub fn verify_sidecar(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + let metadata = std::fs::metadata(path)?; + Ok(metadata.is_file()) +} + +pub fn remove_sidecar(path: &Path) -> Result<()> { + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} + +pub fn get_sidecar_path() -> std::path::PathBuf { + let store = crate::model::store::Store::new(); + store.base_dir.join("bin").join("zesdex-security-daemon") +} diff --git a/src/security/mod.rs b/src/security/mod.rs new file mode 100644 index 0000000..e5b1d65 --- /dev/null +++ b/src/security/mod.rs @@ -0,0 +1 @@ +pub mod install; diff --git a/src/service/mod.rs b/src/service/mod.rs new file mode 100644 index 0000000..c321401 --- /dev/null +++ b/src/service/mod.rs @@ -0,0 +1 @@ +pub mod openrouter; diff --git a/src/service/openrouter.rs b/src/service/openrouter.rs new file mode 100644 index 0000000..5a429dd --- /dev/null +++ b/src/service/openrouter.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use serde_json::Value; + +pub struct OpenRouterClient { + pub client: reqwest::blocking::Client, + pub api_key: String, + pub base_url: String, + pub model: String, +} + +impl OpenRouterClient { + pub fn new(api_key: String, model: String) -> Self { + OpenRouterClient { + client: reqwest::blocking::Client::new(), + api_key, + base_url: "https://openrouter.ai/api/v1".to_string(), + model, + } + } + + pub fn chat(&self, messages: &[crate::dto::chat::message::ChatMessage]) -> Result { + let req = crate::dto::openrouter::request::ChatRequest { + model: self.model.clone(), + messages: messages.to_vec(), + max_tokens: Some(4096), + temperature: Some(0.7), + tools: None, + stream: Some(false), + top_p: None, + stop: None, + }; + + let resp = self.client + .post(format!("{}/chat/completions", self.base_url)) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&req) + .send()?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().unwrap_or_default(); + anyhow::bail!("OpenRouter API error {}: {}", status, body); + } + + let data: Value = resp.json()?; + let content = data["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .to_string(); + Ok(content) + } +} diff --git a/src/tool/fs/edit.rs b/src/tool/fs/edit.rs new file mode 100644 index 0000000..c028d0f --- /dev/null +++ b/src/tool/fs/edit.rs @@ -0,0 +1,94 @@ +use std::fs; +use std::path::PathBuf; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::super::Tool; +use super::super::ToolCtx; +use super::super::resolve_path; +use super::helpers::arg_str; + +pub struct Edit; + +impl Tool for Edit { + fn name(&self) -> &'static str { + "edit" + } + + fn description(&self) -> &'static str { + "Replace a string in a file with a new string. The old string must be unique unless replace_all is true." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to edit (relative to workspace root)" + }, + "old": { + "type": "string", + "description": "The exact text to replace" + }, + "new": { + "type": "string", + "description": "The replacement text" + }, + "replace_all": { + "type": "boolean", + "description": "Replace all occurrences instead of requiring uniqueness" + }, + "reason": { + "type": "string", + "description": "Reason for the change (must be non-empty)" + } + }, + "required": ["path", "old", "new", "reason"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let rel = arg_str(args, "path")?; + let old = arg_str(args, "old")?; + let new_str = arg_str(args, "new")?; + let reason = arg_str(args, "reason")?; + if reason.trim().is_empty() { + anyhow::bail!("reason must be a non-empty string"); + } + let replace_all = args.get("replace_all").and_then(|v| v.as_bool()).unwrap_or(false); + let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; + if !path.exists() { + anyhow::bail!("file '{}' does not exist at resolved path {}", rel, path.display()); + } + if path.is_dir() { + anyhow::bail!("'{}' is a directory, not a file", rel); + } + let content = fs::read_to_string(&path) + .map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?; + if !content.contains(&old) { + anyhow::bail!("old string not found in '{}'", rel); + } + if !replace_all { + let count = content.matches(&old).count(); + if count > 1 { + anyhow::bail!( + "old string appears {} times in '{}'. Set replace_all=true to replace all occurrences, or provide a more specific match.", + count, rel + ); + } + } + let new_content = if replace_all { + content.replace(&old, &new_str) + } else { + content.replacen(&old, &new_str, 1) + }; + fs::write(&path, &new_content) + .map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; + let bytes_diff = if new_content.len() > content.len() { + new_content.len() - content.len() + } else { + content.len() - new_content.len() + }; + Ok(format!("edited {} ({} byte delta)", rel, bytes_diff as isize)) + } +} diff --git a/src/tool/fs/helpers.rs b/src/tool/fs/helpers.rs new file mode 100644 index 0000000..d386da8 --- /dev/null +++ b/src/tool/fs/helpers.rs @@ -0,0 +1,27 @@ +use std::path::Path; +use serde_json::Value; +use anyhow::{Result, anyhow}; + +pub fn arg_str(args: &Value, name: &str) -> Result { + args.get(name) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("missing required argument: {}", name)) +} + +pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String { + let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let in_ws = ctx.workspaces.iter().any(|w| { + let wc = w.canonicalize().unwrap_or_else(|_| w.to_path_buf()); + canon.starts_with(&wc) + }); + if !in_ws { + format!( + "path '{}' is outside all workspace roots. Workspace roots: {}", + rel, + ctx.workspaces.iter().map(|w| w.display().to_string()).collect::>().join(", ") + ) + } else { + format!("path '{}' does not exist (resolved to {})", rel, canon.display()) + } +} diff --git a/src/tool/fs/mod.rs b/src/tool/fs/mod.rs new file mode 100644 index 0000000..b838267 --- /dev/null +++ b/src/tool/fs/mod.rs @@ -0,0 +1,4 @@ +pub mod edit; +pub mod helpers; +pub mod read; +pub mod write; diff --git a/src/tool/fs/read.rs b/src/tool/fs/read.rs new file mode 100644 index 0000000..db976a0 --- /dev/null +++ b/src/tool/fs/read.rs @@ -0,0 +1,70 @@ +use std::fs; +use std::path::PathBuf; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::super::Tool; +use super::super::ToolCtx; +use super::super::resolve_path; +use super::helpers::{arg_str, not_found_help}; + +pub struct Read; + +impl Tool for Read { + fn name(&self) -> &'static str { + "read" + } + + fn description(&self) -> &'static str { + "Read the contents of a file and display it with line numbers" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to read (relative to workspace root, or [N]prefix for other workspaces)" + }, + "limit": { + "type": "integer", + "description": "Maximum number of lines to return (optional)" + } + }, + "required": ["path"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let rel = arg_str(args, "path")?; + let limit = args.get("limit").and_then(|v| v.as_u64()).map(|v| v as usize); + let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) { + Ok(p) => p, + Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)), + }; + if !path.exists() { + return Ok(not_found_help(ctx, &path, &rel)); + } + if path.is_dir() { + return Ok(format!("'{}' is a directory, not a file. Use ls or glob to list directory contents.", rel)); + } + let content = fs::read_to_string(&path) + .map_err(|e| anyhow!("failed to read '{}': {}", rel, e))?; + let lines: Vec<&str> = content.lines().collect(); + let total = lines.len(); + let take = limit.unwrap_or(total).min(total); + let result: String = lines[..take] + .iter() + .enumerate() + .map(|(i, line)| format!("{}\t{}", i + 1, line)) + .collect::>() + .join("\n"); + if take < total { + Ok(format!("{}\n... ({} more lines, total {})", result, total - take, total)) + } else if total == 0 { + Ok(String::new()) + } else { + Ok(result) + } + } +} diff --git a/src/tool/fs/write.rs b/src/tool/fs/write.rs new file mode 100644 index 0000000..6973d04 --- /dev/null +++ b/src/tool/fs/write.rs @@ -0,0 +1,57 @@ +use std::fs; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::super::Tool; +use super::super::ToolCtx; +use super::super::resolve_path; +use super::helpers::arg_str; + +pub struct Write; + +impl Tool for Write { + fn name(&self) -> &'static str { + "write" + } + + fn description(&self) -> &'static str { + "Write content to a file, creating parent directories as needed" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to the file to write (relative to workspace root)" + }, + "content": { + "type": "string", + "description": "Content to write to the file" + }, + "reason": { + "type": "string", + "description": "Reason for the change (must be non-empty)" + } + }, + "required": ["path", "content", "reason"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let rel = arg_str(args, "path")?; + let content = arg_str(args, "content")?; + let reason = arg_str(args, "reason")?; + if reason.trim().is_empty() { + anyhow::bail!("reason must be a non-empty string"); + } + let path = resolve_path(&ctx.workspaces, &rel)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| anyhow!("failed to create parent directories for '{}': {}", rel, e))?; + } + fs::write(&path, &content) + .map_err(|e| anyhow!("failed to write '{}': {}", rel, e))?; + Ok(format!("wrote {} bytes to {}", content.len(), rel)) + } +} diff --git a/src/tool/git_cred.rs b/src/tool/git_cred.rs new file mode 100644 index 0000000..f1867b4 --- /dev/null +++ b/src/tool/git_cred.rs @@ -0,0 +1,50 @@ +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use std::process::Command; +use super::Tool; +use super::ToolCtx; + +pub struct GitCred; + +impl Tool for GitCred { + fn name(&self) -> &'static str { + "git_cred" + } + + fn description(&self) -> &'static str { + "Interact with git credential helper (store, get, erase credentials)" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["store", "get", "erase"], + "description": "Git credential operation" + } + }, + "required": ["operation"] + }) + } + + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { + let operation = args.get("operation") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: operation"))?; + let output = Command::new("git") + .arg("credential") + .arg(operation) + .output() + .map_err(|e| anyhow!("git credential failed: {}", e))?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok(format!("{}{}", stdout, stderr)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim()) + } + } +} diff --git a/src/tool/git_operator.rs b/src/tool/git_operator.rs new file mode 100644 index 0000000..f4cb9a1 --- /dev/null +++ b/src/tool/git_operator.rs @@ -0,0 +1,67 @@ +use std::process::Command; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::Tool; +use super::ToolCtx; + +pub struct GitOperator; + +impl Tool for GitOperator { + fn name(&self) -> &'static str { + "git_operator" + } + + fn description(&self) -> &'static str { + "Execute git operations with catastrophic guard protection" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "operation": { + "type": "string", + "description": "Git subcommand to execute (e.g. 'add', 'commit', 'status')" + }, + "args": { + "type": "array", + "items": {"type": "string"}, + "description": "Arguments for the git subcommand" + } + }, + "required": ["operation", "args"] + }) + } + + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { + let operation = args.get("operation") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: operation"))? + .to_string(); + let arg_list: Vec = args.get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .ok_or_else(|| anyhow!("missing required argument: args"))?; + let full_cmd_str = format!("git {} {}", operation, arg_list.join(" ")); + let workspace_roots: Vec<&std::path::Path> = _ctx.workspaces.iter().map(|p| p.as_path()).collect(); + crate::app::catastrophic::CatastrophicGuard::check_all(&full_cmd_str, &workspace_roots) + .map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?; + let output = Command::new("git") + .arg(&operation) + .args(&arg_list) + .output() + .map_err(|e| anyhow!("git {} failed: {}", operation, e))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) }; + if output.status.success() { + Ok(combined) + } else { + anyhow::bail!("git {} failed (exit {}): {}", operation, output.status.code().unwrap_or(-1), stderr.trim()) + } + } +} diff --git a/src/tool/git_worktree.rs b/src/tool/git_worktree.rs new file mode 100644 index 0000000..76a869b --- /dev/null +++ b/src/tool/git_worktree.rs @@ -0,0 +1,62 @@ +use std::process::Command; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::Tool; +use super::ToolCtx; + +pub struct GitWorktree; + +impl Tool for GitWorktree { + fn name(&self) -> &'static str { + "git_worktree" + } + + fn description(&self) -> &'static str { + "Create and manage git worktrees" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the worktree directory" + }, + "base_ref": { + "type": "string", + "description": "Base branch or ref to create the worktree from (e.g. 'main')" + } + }, + "required": ["name", "base_ref"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let name = args.get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: name"))? + .to_string(); + let base_ref = args.get("base_ref") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: base_ref"))? + .to_string(); + let worktree_path = ctx.worktrees_dir.join(&name); + std::fs::create_dir_all(&worktree_path) + .map_err(|e| anyhow!("failed to create worktree directory: {}", e))?; + let output = Command::new("git") + .args(["worktree", "add", "--checkout"]) + .arg(worktree_path.display().to_string()) + .arg(&base_ref) + .output() + .map_err(|e| anyhow!("git worktree add failed: {}", e))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = if stderr.is_empty() { stdout.trim().to_string() } else { format!("{}\n{}", stdout.trim(), stderr.trim()) }; + if output.status.success() { + Ok(format!("created worktree '{}' from '{}'\n{}", name, base_ref, combined)) + } else { + anyhow::bail!("git worktree add failed (exit {}): {}", output.status.code().unwrap_or(-1), stderr.trim()) + } + } +} diff --git a/src/tool/internet/download.rs b/src/tool/internet/download.rs new file mode 100644 index 0000000..0d2b6d6 --- /dev/null +++ b/src/tool/internet/download.rs @@ -0,0 +1,78 @@ +use std::fs; +use std::io::copy; +use std::path::PathBuf; +use std::time::Duration; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::super::Tool; +use super::super::ToolCtx; +use super::super::resolve_path; + +pub struct Download; + +impl Tool for Download { + fn name(&self) -> &'static str { + "download" + } + + fn description(&self) -> &'static str { + "Download a file from a URL to a local path" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to download from" + }, + "path": { + "type": "string", + "description": "Local path to save the file (relative to workspace root)" + } + }, + "required": ["url", "path"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + if ctx.internet_mode == crate::model::settings::InternetMode::Off { + anyhow::bail!("internet access is disabled. Enable it in settings to use download."); + } + let url = args.get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: url"))? + .to_string(); + let rel = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))? + .to_string(); + let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; + crate::app::catastrophic::CatastrophicGuard::check_download_path(&path) + .map_err(|e| anyhow!("download blocked: {}", e))?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| anyhow!("failed to create parent directories: {}", e))?; + } + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .user_agent("ZedSex/1.0") + .build() + .map_err(|e| anyhow!("failed to create HTTP client: {}", e))?; + let response = client.get(&url) + .send() + .map_err(|e| anyhow!("failed to download '{}': {}", url, e))?; + let status = response.status(); + if !status.is_success() { + anyhow::bail!("download '{}' returned HTTP {}", url, status.as_u16()); + } + let total: u64 = response.content_length().unwrap_or(0); + let mut file = fs::File::create(&path) + .map_err(|e| anyhow!("failed to create file '{}': {}", rel, e))?; + let mut content = response; + let written = copy(&mut content, &mut file) + .map_err(|e| anyhow!("failed to write to '{}': {}", rel, e))?; + Ok(format!("downloaded {} of {} bytes to {}", written, total, rel)) + } +} diff --git a/src/tool/internet/fetch.rs b/src/tool/internet/fetch.rs new file mode 100644 index 0000000..67e45b3 --- /dev/null +++ b/src/tool/internet/fetch.rs @@ -0,0 +1,78 @@ +use std::time::Duration; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::super::Tool; +use super::super::ToolCtx; + +pub struct Fetch; + +impl Tool for Fetch { + fn name(&self) -> &'static str { + "fetch" + } + + fn description(&self) -> &'static str { + "Fetch a URL and convert the HTML content to markdown" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "URL to fetch" + } + }, + "required": ["url"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + if ctx.internet_mode == crate::model::settings::InternetMode::Off { + anyhow::bail!("internet access is disabled. Enable it in settings to use fetch."); + } + let url = args.get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: url"))? + .to_string(); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("ZedSex/1.0") + .build() + .map_err(|e| anyhow!("failed to create HTTP client: {}", e))?; + let response = client.get(&url) + .send() + .map_err(|e| anyhow!("failed to fetch '{}': {}", url, e))?; + let status = response.status(); + if !status.is_success() { + anyhow::bail!("fetch '{}' returned HTTP {}", url, status.as_u16()); + } + let content_type = response.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = response.text() + .map_err(|e| anyhow!("failed to read response body: {}", e))?; + if content_type.contains("text/html") || content_type.contains("application/xhtml") || content_type.is_empty() { + let markdown = html_to_markdown(&body)?; + Ok(markdown) + } else { + let preview = body.chars().take(2000).collect::(); + Ok(format!("Content-Type: {}\n\n{}", content_type, preview)) + } + } +} + +pub(crate) fn html_to_markdown(html: &str) -> Result { + let frag = scraper::Html::parse_document(html); + let sel = scraper::Selector::parse("body") + .map_err(|e| anyhow!("failed to parse selector: {}", e))?; + let body = frag.select(&sel).next() + .map(|e| e.inner_html()) + .unwrap_or_else(|| html.to_string()); + let text = scraper::Html::parse_fragment(&body); + let result: String = text.root_element().text().collect::>().join("\n"); + Ok(result) +} diff --git a/src/tool/internet/mod.rs b/src/tool/internet/mod.rs new file mode 100644 index 0000000..ac6c639 --- /dev/null +++ b/src/tool/internet/mod.rs @@ -0,0 +1,3 @@ +pub mod download; +pub mod fetch; +pub mod search; diff --git a/src/tool/internet/search.rs b/src/tool/internet/search.rs new file mode 100644 index 0000000..0ee0acb --- /dev/null +++ b/src/tool/internet/search.rs @@ -0,0 +1,50 @@ +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::super::Tool; +use super::super::ToolCtx; + +pub struct Search; + +impl Tool for Search { + fn name(&self) -> &'static str { + "web_search" + } + + fn description(&self) -> &'static str { + "Search the web for information. Uses configured search provider." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query" + } + }, + "required": ["query"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + if ctx.internet_mode == crate::model::settings::InternetMode::Off { + anyhow::bail!("internet access is disabled. Enable it in settings to use web_search."); + } + let query = args.get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: query"))? + .to_string(); + let results = mock_search(&query); + Ok(results) + } +} + +pub(crate) fn mock_search(query: &str) -> String { + format!( + "Search results for '{}':\n\n\ + No search provider configured. Results are unavailable.\n\ + To enable web search, configure a search provider in settings.\n\ + Supported providers: tavily, brave, serpapi, google.", query + ) +} diff --git a/src/tool/mod.rs b/src/tool/mod.rs new file mode 100644 index 0000000..f25734b --- /dev/null +++ b/src/tool/mod.rs @@ -0,0 +1,137 @@ +use std::path::PathBuf; +use serde_json::Value; +use anyhow::Result; + +pub mod fs; +pub mod git_cred; +pub mod git_operator; +pub mod git_worktree; +pub mod internet; +pub mod plan; +pub mod search; +pub mod seqthink; +pub mod shell; +pub mod shell_filter; +pub mod workflow; + +pub trait Tool: Send + Sync { + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + fn parameters(&self) -> Value; + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; +} + +pub struct ToolCtx { + pub workspaces: Vec, + pub session_dir: PathBuf, + pub memory_dir: PathBuf, + pub download_dir: PathBuf, + pub worktrees_dir: PathBuf, + pub dir_cache: std::sync::Arc>, + pub internet_mode: super::model::settings::InternetMode, + pub origin: crate::app::state::types::Origin, +} + +impl ToolCtx { + pub fn builder() -> ToolCtxBuilder { + ToolCtxBuilder::default() + } +} + +pub struct ToolCtxBuilder { + pub workspaces: Vec, + pub session_dir: PathBuf, + pub memory_dir: PathBuf, + pub download_dir: PathBuf, + pub worktrees_dir: PathBuf, + pub dir_cache: std::sync::Arc>, + pub internet_mode: super::model::settings::InternetMode, + pub origin: crate::app::state::types::Origin, +} + +impl Default for ToolCtxBuilder { + fn default() -> Self { + ToolCtxBuilder { + workspaces: Vec::new(), + session_dir: PathBuf::new(), + memory_dir: PathBuf::new(), + download_dir: PathBuf::new(), + worktrees_dir: PathBuf::new(), + dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new(super::app::state::misc::DirCache::new())), + internet_mode: super::model::settings::InternetMode::Off, + origin: crate::app::state::types::Origin::Main, + } + } +} + +impl ToolCtxBuilder { + pub fn workspaces(mut self, v: Vec) -> Self { self.workspaces = v; self } + pub fn session_dir(mut self, v: PathBuf) -> Self { self.session_dir = v; self } + pub fn memory_dir(mut self, v: PathBuf) -> Self { self.memory_dir = v; self } + pub fn download_dir(mut self, v: PathBuf) -> Self { self.download_dir = v; self } + pub fn worktrees_dir(mut self, v: PathBuf) -> Self { self.worktrees_dir = v; self } + pub fn internet_mode(mut self, v: super::model::settings::InternetMode) -> Self { self.internet_mode = v; self } + pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { self.origin = v; self } + pub fn build(self) -> ToolCtx { + ToolCtx { + workspaces: self.workspaces, + session_dir: self.session_dir, + memory_dir: self.memory_dir, + download_dir: self.download_dir, + worktrees_dir: self.worktrees_dir, + dir_cache: self.dir_cache, + internet_mode: self.internet_mode, + origin: self.origin, + } + } +} + +pub fn all_tools() -> Vec> { + vec![ + Box::new(super::tool::fs::read::Read), + Box::new(super::tool::fs::write::Write), + Box::new(super::tool::fs::edit::Edit), + Box::new(super::tool::search::Grep), + Box::new(super::tool::search::Glob), + Box::new(super::tool::shell::Bash), + Box::new(super::tool::git_operator::GitOperator), + Box::new(super::tool::git_worktree::GitWorktree), + Box::new(super::tool::git_cred::GitCred), + Box::new(super::tool::seqthink::SeqThink), + Box::new(super::tool::plan::PlanEnter), + Box::new(super::tool::plan::PlanReady), + Box::new(super::tool::workflow::WorkflowRun), + ] +} + +pub fn tool_is_risky(name: &str) -> bool { + matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") +} + +pub const DEFERRED_TOOLS: &[&str] = &[ + "read", "write", "edit", "bash", "grep", "glob", + "git_operator", "git_worktree", "git_cred", +]; + +pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { + let _parts: Vec<&str> = rel.splitn(2, '/').collect(); + let (ws_idx, path) = if rel.starts_with('[') { + let close = rel.find(']').ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; + let idx: usize = rel[1..close].parse().map_err(|_| anyhow::anyhow!("invalid workspace index"))?; + (idx, &rel[close + 1..]) + } else { + (0, rel) + }; + let base = workspaces.get(ws_idx).ok_or_else(|| anyhow::anyhow!("workspace index {} out of range", ws_idx))?; + let abs = if path.is_empty() { + base.clone() + } else { + base.join(path) + }; + let canon = abs.canonicalize().unwrap_or(abs); + if workspaces.iter().any(|w| canon.starts_with(w)) { + Ok(canon) + } else { + anyhow::bail!("path '{}' is outside all workspace roots", rel) + } +} diff --git a/src/tool/plan.rs b/src/tool/plan.rs new file mode 100644 index 0000000..df8d858 --- /dev/null +++ b/src/tool/plan.rs @@ -0,0 +1,75 @@ +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::Tool; +use super::ToolCtx; + +pub struct PlanEnter; + +impl Tool for PlanEnter { + fn name(&self) -> &'static str { + "plan_enter" + } + + fn description(&self) -> &'static str { + "Enter plan mode: provide a detailed plan for the next set of changes" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "The step-by-step plan to implement" + }, + "sign_off": { + "type": "string", + "description": "Sign-off message acknowledging the plan constraints" + } + }, + "required": ["plan", "sign_off"] + }) + } + + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { + let _plan = args.get("plan") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: plan"))?; + let _sign_off = args.get("sign_off") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: sign_off"))?; + Ok("plan recorded".to_string()) + } +} + +pub struct PlanReady; + +impl Tool for PlanReady { + fn name(&self) -> &'static str { + "plan_ready" + } + + fn description(&self) -> &'static str { + "Signal that you are ready to execute the approved plan" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "confirmation": { + "type": "string", + "description": "Confirmation that you understand and will follow the plan" + } + }, + "required": ["confirmation"] + }) + } + + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { + let _confirmation = args.get("confirmation") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: confirmation"))?; + Ok("ready to execute".to_string()) + } +} diff --git a/src/tool/search.rs b/src/tool/search.rs new file mode 100644 index 0000000..c4a251a --- /dev/null +++ b/src/tool/search.rs @@ -0,0 +1,147 @@ +use std::fs; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use ignore::Walk; +use globset::{GlobBuilder, GlobSetBuilder}; +use super::Tool; +use super::ToolCtx; +use super::resolve_path; + +pub struct Grep; + +impl Tool for Grep { + fn name(&self) -> &'static str { + "grep" + } + + fn description(&self) -> &'static str { + "Search for a pattern in files using recursive text search" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Text pattern to search for" + }, + "path": { + "type": "string", + "description": "Path to search in (relative to workspace root)" + } + }, + "required": ["pattern", "path"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let pattern = args.get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: pattern"))? + .to_string(); + let rel = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))? + .to_string(); + let path = resolve_path(&ctx.workspaces, &rel)?; + if !path.exists() { + anyhow::bail!("path '{}' does not exist", rel); + } + if !path.is_dir() { + anyhow::bail!("path '{}' is not a directory", rel); + } + let mut results: Vec<(String, usize, String)> = Vec::new(); + for entry in Walk::new(&path).flatten() { + let file_path = entry.path(); + if !file_path.is_file() { + continue; + } + if let Ok(content) = fs::read_to_string(file_path) { + for (i, line) in content.lines().enumerate() { + if line.contains(&pattern) { + let rel_path = file_path.strip_prefix(&path) + .unwrap_or(file_path) + .display() + .to_string(); + results.push((rel_path, i + 1, line.to_string())); + } + } + } + } + if results.is_empty() { + return Ok(format!("no matches found for '{}' in {}", pattern, rel)); + } + let output = results.iter() + .map(|(f, line, text)| format!("{}:{}:{}", f, line, text)) + .collect::>() + .join("\n"); + Ok(format!("found {} matches:\n{}", results.len(), output)) + } +} + +pub struct Glob; + +impl Tool for Glob { + fn name(&self) -> &'static str { + "glob" + } + + fn description(&self) -> &'static str { + "List files matching a glob pattern" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern to match files (e.g. '**/*.rs')" + }, + "path": { + "type": "string", + "description": "Root path to search from (relative to workspace root)" + } + }, + "required": ["pattern", "path"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let pat_str = args.get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: pattern"))? + .to_string(); + let rel = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: path"))? + .to_string(); + let root = resolve_path(&ctx.workspaces, &rel)?; + if !root.exists() || !root.is_dir() { + anyhow::bail!("path '{}' is not a valid directory", rel); + } + let mut builder = GlobSetBuilder::new(); + let full_pattern = root.join(&pat_str).display().to_string(); + builder.add(GlobBuilder::new(&full_pattern).build() + .map_err(|e| anyhow!("invalid glob pattern '{}': {}", pat_str, e))?); + let glob_set = builder.build() + .map_err(|e| anyhow!("failed to build glob set: {}", e))?; + let mut matches: Vec = Vec::new(); + for entry in Walk::new(&root).flatten() { + let p = entry.path(); + if glob_set.is_match(p) { + let rel_path = p.strip_prefix(&root) + .unwrap_or(p) + .display() + .to_string(); + matches.push(format!("{}{}", rel_path, if p.is_dir() { "/" } else { "" })); + } + } + matches.sort(); + if matches.is_empty() { + return Ok(format!("no files match '{}' in {}", pat_str, rel)); + } + Ok(matches.join("\n")) + } +} diff --git a/src/tool/seqthink.rs b/src/tool/seqthink.rs new file mode 100644 index 0000000..8c56e70 --- /dev/null +++ b/src/tool/seqthink.rs @@ -0,0 +1,33 @@ +use serde_json::{json, Value}; +use anyhow::Result; +use super::Tool; +use super::ToolCtx; + +pub struct SeqThink; + +impl Tool for SeqThink { + fn name(&self) -> &'static str { + "seqthink" + } + + fn description(&self) -> &'static str { + "Record a step in sequential thinking. Use this to show your reasoning chain step by step." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "thought": { + "type": "string", + "description": "The current thinking step content" + } + }, + "required": ["thought"] + }) + } + + fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result { + Ok(String::new()) + } +} diff --git a/src/tool/shell.rs b/src/tool/shell.rs new file mode 100644 index 0000000..b71feb3 --- /dev/null +++ b/src/tool/shell.rs @@ -0,0 +1,93 @@ +use std::process::Command; +use std::time::Duration; +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::Tool; +use super::ToolCtx; + +pub struct Bash; + +impl Tool for Bash { + fn name(&self) -> &'static str { + "bash" + } + + fn description(&self) -> &'static str { + "Execute a shell command via bash -c with catastrophic guard protection" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Shell command to execute" + }, + "description": { + "type": "string", + "description": "Human-readable description of what the command does" + }, + "timeout": { + "type": "integer", + "description": "Timeout in milliseconds (default 120000, max 600000)" + } + }, + "required": ["command"] + }) + } + + fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + let cmd = args.get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: command"))? + .to_string(); + let _description = args.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let timeout_ms = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(120000).min(600000); + let workspace_roots: Vec<&std::path::Path> = ctx.workspaces.iter().map(|p| p.as_path()).collect(); + crate::app::catastrophic::CatastrophicGuard::check_all(&cmd, &workspace_roots) + .map_err(|e| anyhow!("catastrophic guard blocked: {}", e))?; + let mut child = Command::new("bash") + .arg("-c") + .arg(&cmd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| anyhow!("failed to spawn bash: {}", e))?; + let start = std::time::Instant::now(); + let timeout = Duration::from_millis(timeout_ms); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let elapsed = start.elapsed().as_secs_f64(); + let output = child.wait_with_output() + .map_err(|e| anyhow!("failed to collect output: {}", e))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; + let trimmed = combined.trim().to_string(); + if status.success() { + return Ok(if trimmed.is_empty() { + format!("Command completed in {:.2}s (exit code 0)", elapsed) + } else { + format!("{}\n\nExit code: 0 ({:.2}s)", trimmed, elapsed) + }); + } else { + return Ok(format!("{}\n\nExit code: {} ({:.2}s)", trimmed, status.code().unwrap_or(-1), elapsed)); + } + } + Ok(None) => { + if start.elapsed() > timeout { + let _ = child.kill(); + let _ = child.wait(); + anyhow::bail!("command timed out after {}ms", timeout_ms); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(e) => { + anyhow::bail!("failed to wait for command: {}", e); + } + } + } + } +} diff --git a/src/tool/shell_filter/credentials.rs b/src/tool/shell_filter/credentials.rs new file mode 100644 index 0000000..1a22a41 --- /dev/null +++ b/src/tool/shell_filter/credentials.rs @@ -0,0 +1,32 @@ +use anyhow::Result; + +pub fn check_credential_read(cmd: &str) -> Result<()> { + let patterns = [ + "cat ~/.ssh", + "cat /home/", + ".ssh/id_rsa", + ".ssh/id_ed25519", + ".ssh/id_ecdsa", + ".ssh/id_dsa", + ".ssh/authorized_keys", + ".ssh/known_hosts", + ".git-credentials", + ".netrc", + "aws/credentials", + "gcloud/credentials", + ".config/gcloud", + ".config/gh", + "token=", + "secret=", + "api_key=", + "api-key=", + "password=", + ]; + let cmd_lower = cmd.to_lowercase(); + for pattern in &patterns { + if cmd_lower.contains(pattern) { + anyhow::bail!("credential read blocked: '{}'", pattern); + } + } + Ok(()) +} diff --git a/src/tool/shell_filter/git.rs b/src/tool/shell_filter/git.rs new file mode 100644 index 0000000..84c34ce --- /dev/null +++ b/src/tool/shell_filter/git.rs @@ -0,0 +1,38 @@ +use anyhow::Result; + +pub fn check_git_destructive(cmd: &str) -> Result<()> { + let patterns = [ + "force-push", + "reset --hard", + "clean -fdx", + "clean -fd", + "clean -fX", + "clean -fx", + "branch -D", + "branch --delete --force", + "checkout --force", + "switch -f", + "restore --force", + "stash drop", + "stash clear", + "tag -d", + "tag --delete", + "update-ref -d", + "filter-branch", + "gc --prune", + "gc --aggressive", + "push --delete", + "push --force", + "push origin :", + "push +refs", + "push --mirror", + "push --tags --force", + ]; + let cmd_lower = cmd.to_lowercase(); + for pattern in &patterns { + if cmd_lower.contains(pattern) { + anyhow::bail!("destructive git operation blocked: '{}'", pattern); + } + } + Ok(()) +} diff --git a/src/tool/shell_filter/mod.rs b/src/tool/shell_filter/mod.rs new file mode 100644 index 0000000..77f9980 --- /dev/null +++ b/src/tool/shell_filter/mod.rs @@ -0,0 +1,2 @@ +pub mod credentials; +pub mod git; diff --git a/src/tool/workflow.rs b/src/tool/workflow.rs new file mode 100644 index 0000000..765dd5a --- /dev/null +++ b/src/tool/workflow.rs @@ -0,0 +1,41 @@ +use serde_json::{json, Value}; +use anyhow::{Result, anyhow}; +use super::Tool; +use super::ToolCtx; + +pub struct WorkflowRun; + +impl Tool for WorkflowRun { + fn name(&self) -> &'static str { + "workflow_run" + } + + fn description(&self) -> &'static str { + "Execute a workflow script by delegating to the workflow engine" + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "Workflow script content or path to a workflow file" + }, + "args": { + "type": "object", + "description": "Optional arguments passed to the workflow script" + } + }, + "required": ["script"] + }) + } + + fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { + let _script = args.get("script") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("missing required argument: script"))?; + let _workflow_args = args.get("args"); + Ok("workflow delegated to workflow engine".to_string()) + } +} diff --git a/src/view/chat.rs b/src/view/chat.rs new file mode 100644 index 0000000..16ae820 --- /dev/null +++ b/src/view/chat.rs @@ -0,0 +1,134 @@ +use ratatui::layout::Rect; +use ratatui::style::{Style, Modifier}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use super::theme::Theme; + +fn _role_name(role: &crate::dto::chat::message::Role) -> &'static str { + match role { + crate::dto::chat::message::Role::User => "User", + crate::dto::chat::message::Role::Assistant => "Assistant", + crate::dto::chat::message::Role::System => "System", + crate::dto::chat::message::Role::Tool => "Tool", + } +} + +fn role_badge(role: &crate::dto::chat::message::Role) -> &'static str { + match role { + crate::dto::chat::message::Role::User => "YOU", + crate::dto::chat::message::Role::Assistant => "AI ", + crate::dto::chat::message::Role::System => "SYS", + crate::dto::chat::message::Role::Tool => "TOOL", + } +} + +fn format_timestamp(ts: i64) -> String { + if ts <= 0 { return "".to_string(); } + let secs = ts / 1000; + let mins = (secs / 60) % 60; + let hrs = (secs / 3600) % 24; + format!("{:02}:{:02}", hrs, mins) +} + +pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + let messages = &state.transcript_cache.messages; + let scroll_offset = state.scroll.offset; + let max_visible = (area.height as usize).saturating_sub(3); + + let mut display_lines: Vec = Vec::new(); + + if scroll_offset > 0 { + let above = messages.len().saturating_sub(scroll_offset + max_visible).saturating_sub(1); + if above > 0 { + display_lines.push(Line::from(Span::styled( + format!(" ↑ {} more messages above", above), + Style::default().fg(Theme::DIM), + ))); + } + } + + let iter_start = if scroll_offset + max_visible >= messages.len() { + 0usize + } else { + messages.len().saturating_sub(scroll_offset + max_visible) + }; + + for msg in messages.iter().skip(iter_start).take(scroll_offset + max_visible) { + let role_color = match msg.role { + crate::dto::chat::message::Role::User => Theme::ROLE_USER, + crate::dto::chat::message::Role::Assistant => Theme::ROLE_ASSISTANT, + crate::dto::chat::message::Role::System => Theme::ROLE_SYSTEM, + crate::dto::chat::message::Role::Tool => Theme::ROLE_TOOL, + }; + let prefix = match msg.role { + crate::dto::chat::message::Role::User => ">", + crate::dto::chat::message::Role::Assistant => "•", + crate::dto::chat::message::Role::System => "#", + crate::dto::chat::message::Role::Tool => "→", + }; + + let ts_str = format_timestamp(msg.timestamp); + let badge = role_badge(&msg.role); + let time_display = if ts_str.is_empty() { String::new() } else { format!(" [{}]", ts_str) }; + + let header = Line::from(vec![ + Span::styled( + format!(" {} ", badge), + Style::default().fg(Theme::BG).bg(role_color).add_modifier(Modifier::BOLD), + ), + Span::styled( + time_display, + Style::default().fg(Theme::DIM), + ), + ]); + + let content_str = if msg.content.is_empty() { + "(streaming...)".to_string() + } else { + msg.content.clone() + }; + let content_line = Line::from(vec![ + Span::styled(format!("{} ", prefix), Style::default().fg(role_color)), + Span::styled(content_str, Style::default().fg(Theme::TEXT)), + ]); + + display_lines.push(header); + display_lines.push(content_line); + display_lines.push(Line::from(Span::raw(""))); + } + + if messages.len() > scroll_offset + max_visible { + let below = messages.len().saturating_sub(scroll_offset + max_visible); + display_lines.push(Line::from(Span::styled( + format!(" ↓ {} more messages below", below), + Style::default().fg(Theme::DIM), + ))); + } + + let mut title = String::from(" Chat "); + if !messages.is_empty() { + title.push_str(&format!("[{} msgs]", messages.len())); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::BORDER)) + .title(title); + + let total = display_lines.len(); + let end_idx = total.saturating_sub(scroll_offset); + let start_idx = end_idx.saturating_sub(max_visible); + let end_idx = end_idx.min(total); + let visible: Vec = if start_idx < end_idx { + display_lines[start_idx..end_idx].to_vec() + } else { + Vec::new() + }; + + let paragraph = Paragraph::new(visible) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} diff --git a/src/view/markdown.rs b/src/view/markdown.rs new file mode 100644 index 0000000..f506adb --- /dev/null +++ b/src/view/markdown.rs @@ -0,0 +1,143 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; + +pub fn render_markdown<'a>(text: &'a str, width: u16) -> Vec> { + let mut spans = Vec::new(); + let parser = pulldown_cmark::Parser::new(text); + let mut in_code_block = false; + + for event in parser { + match event { + pulldown_cmark::Event::Start(tag) => { + match tag { + pulldown_cmark::Tag::CodeBlock(_) => { + in_code_block = true; + spans.push(Span::styled( + "```\n", + Style::default().fg(Color::DarkGray), + )); + } + pulldown_cmark::Tag::Heading { level, .. } => { + let color = match level { + pulldown_cmark::HeadingLevel::H1 => Color::LightCyan, + pulldown_cmark::HeadingLevel::H2 => Color::Cyan, + _ => Color::White, + }; + let prefix = match level { + pulldown_cmark::HeadingLevel::H1 => "# ", + pulldown_cmark::HeadingLevel::H2 => "## ", + pulldown_cmark::HeadingLevel::H3 => "### ", + _ => "# ", + }; + spans.push(Span::styled( + prefix, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )); + } + pulldown_cmark::Tag::Paragraph => {} + pulldown_cmark::Tag::Emphasis => {} + pulldown_cmark::Tag::Strong => {} + pulldown_cmark::Tag::List(_) => {} + pulldown_cmark::Tag::Item => { + spans.push(Span::styled( + " * ", + Style::default().fg(Color::DarkGray), + )); + } + pulldown_cmark::Tag::Link { dest_url, .. } => { + spans.push(Span::styled( + "[", + Style::default().fg(Color::Cyan), + )); + spans.push(Span::styled( + format!("]({})", dest_url), + Style::default().fg(Color::Blue), + )); + } + pulldown_cmark::Tag::BlockQuote(_) => { + spans.push(Span::styled( + "> ", + Style::default().fg(Color::DarkGray), + )); + } + _ => {} + } + } + pulldown_cmark::Event::End(tag) => { + match tag { + pulldown_cmark::TagEnd::CodeBlock => { + in_code_block = false; + spans.push(Span::styled( + "\n```\n", + Style::default().fg(Color::DarkGray), + )); + } + pulldown_cmark::TagEnd::Heading(_) => { + spans.push(Span::raw("\n")); + } + pulldown_cmark::TagEnd::Paragraph => { + spans.push(Span::raw("\n\n")); + } + pulldown_cmark::TagEnd::Emphasis => {} + pulldown_cmark::TagEnd::Strong => {} + pulldown_cmark::TagEnd::List(_) => {} + pulldown_cmark::TagEnd::Item => { + spans.push(Span::raw("\n")); + } + pulldown_cmark::TagEnd::Link => {} + pulldown_cmark::TagEnd::BlockQuote(_) => { + spans.push(Span::raw("\n")); + } + _ => {} + } + } + pulldown_cmark::Event::Text(text) => { + let s = text.to_string(); + if in_code_block { + spans.push(Span::styled( + s, + Style::default().fg(Color::Green), + )); + } else { + spans.push(Span::raw(s)); + } + } + pulldown_cmark::Event::Code(text) => { + spans.push(Span::styled( + format!("`{}`", text), + Style::default().fg(Color::Green), + )); + } + pulldown_cmark::Event::SoftBreak => { + spans.push(Span::raw("\n")); + } + pulldown_cmark::Event::HardBreak => { + spans.push(Span::raw("\n")); + } + _ => {} + } + } + + if width > 0 { + let mut spans_out = Vec::new(); + let mut line_len = 0; + for span in &spans { + let s = span.content.clone(); + let text = s.as_ref(); + let remaining = text.len(); + if line_len + remaining > width as usize && line_len > 0 { + spans_out.push(Span::raw("\n")); + line_len = 0; + } + spans_out.push(Span::raw(text.to_string())); + if !text.contains('\n') { + line_len += remaining; + } else { + line_len = text.split('\n').next_back().unwrap_or("").len(); + } + } + spans = spans_out; + } + + spans +} diff --git a/src/view/mod.rs b/src/view/mod.rs new file mode 100644 index 0000000..0a74dae --- /dev/null +++ b/src/view/mod.rs @@ -0,0 +1,642 @@ +pub mod chat; +pub mod markdown; +pub mod status; +pub mod theme; +pub mod workflow; + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Modifier}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::Frame; +use theme::Theme; + +pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) { + let area = frame.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(3), + Constraint::Length(3), + Constraint::Length(1), + ]) + .split(area); + + let main_area = chunks[0]; + let input_area = chunks[1]; + let status_area = chunks[2]; + + if state.misc.overlay.is_active() { + let overlay = state.misc.overlay; + render_overlay(frame, main_area, overlay, state); + } else { + render_main_panel(frame, main_area, state); + } + + render_input_bar(frame, input_area, state); + status::draw_status_bar(frame, status_area, state); +} + +fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + match state.mode { + crate::app::state::types::AgentMode::Auto + | crate::app::state::types::AgentMode::Normal + | crate::app::state::types::AgentMode::Plan + | crate::app::state::types::AgentMode::Yolo => { + chat::draw_chat(frame, area, state); + } + } +} + +fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::types::Overlay, state: &crate::app::state::rest::AppStateRest) { + let overlay_area = centered_rect(area, 70, 60); + + frame.render_widget(Clear, overlay_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::PRIMARY)) + .style(Style::default().bg(Theme::BG)); + + match overlay { + crate::app::state::types::Overlay::None => {} + crate::app::state::types::Overlay::Help => { + let block = block.title(" Help "); + let content = crate::resources::HELP_TEXT; + let paragraph = Paragraph::new(content) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Settings => { + let block = block.title(" Settings "); + let lines = vec![ + Line::from(Span::styled( + format!("Provider: {}", state.settings.provider), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Model: {}", state.settings.model), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Max tokens: {}", state.settings.max_tokens), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Temperature: {:.1}", state.settings.temperature), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Internet mode: {:?}", state.settings.internet_mode), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Review enabled: {}", state.settings.review_enabled), + Style::default().fg(Theme::TEXT), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Agents => { + let block = block.title(" Agents "); + let lines = vec![ + Line::from(Span::styled( + "Sub-agent management panel", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Subagent queue: {}", state.session_runtime.as_ref().map(|r| r.subagent_queue).unwrap_or(0)), + Style::default().fg(Theme::INFO), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Bash => { + let block = block.title(" Bash "); + let lines: Vec = state.session_runtime.as_ref().map(|r| { + r.bash_jobs.iter().map(|job| { + Line::from(Span::styled( + format!("[{}] {} - {}", job.id, job.command, if job.running { "running" } else { "done" }), + Style::default().fg(Theme::TEXT), + )) + }).collect() + }).unwrap_or_default(); + let paragraph = if lines.is_empty() { + Paragraph::new(Line::from(Span::styled( + "No active bash jobs", + Style::default().fg(Theme::DIM), + ))).block(block) + } else { + Paragraph::new(lines).block(block) + }; + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::QuitConfirm => { + let block = block + .title(" Quit ") + .border_style(Style::default().fg(Theme::ERROR)); + let lines = vec![ + Line::from(Span::styled( + "Are you sure you want to quit?", + Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + "Press Ctrl+Q again to confirm, Esc to cancel.", + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::SessionHub => { + let block = block.title(" Sessions "); + let lines: Vec = state.sessions.iter().map(|s| { + Line::from(Span::styled( + format!("{} | {}", s.title, s.model), + Style::default().fg(Theme::TEXT), + )) + }).collect(); + let paragraph = if lines.is_empty() { + Paragraph::new(Line::from(Span::styled( + "No sessions", + Style::default().fg(Theme::DIM), + ))).block(block) + } else { + Paragraph::new(lines).block(block) + }; + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Workflow => { + workflow::draw_workflow_panel(frame, overlay_area, state); + } + crate::app::state::types::Overlay::Onboard => { + let block = block.title(" Welcome to Zesdex "); + let content = "Press Ctrl+H for help, Ctrl+P for settings, or start typing to chat."; + let paragraph = Paragraph::new(content) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::OnboardProvider => { + let block = block.title(" Select Provider "); + let lines = vec![ + Line::from(Span::styled( + "Configure AI Provider", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + "Available providers:", + Style::default().fg(Theme::DIM), + )), + Line::from(Span::styled( + " OpenRouter - Multi-model API gateway", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + " Anthropic - Direct Claude API", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + " OpenAI - Direct GPT API", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + format!("Current: {} / {}", state.settings.provider, state.settings.model), + Style::default().fg(Theme::INFO), + )), + Line::from(Span::styled( + "Press Ctrl+P to change settings.", + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Picker => { + let block = block.title(" Select "); + let lines = vec![ + Line::from(Span::styled( + "Selection Picker", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + format!("Filter: {}", state.input.buffer), + Style::default().fg(Theme::DIM), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + "Press Enter to select, Esc to cancel.", + Style::default().fg(Theme::DIM), + )), + Line::from(Span::styled( + "Type to filter available options.", + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::KeyInput => { + let block = block.title(" Input "); + let input_text = &state.input.buffer; + let display = if input_text.is_empty() { + "Type your input..." + } else { + input_text.as_str() + }; + let paragraph = Paragraph::new(display) + .block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Editor => { + let block = block.title(" Editor "); + let lines = vec![ + Line::from(Span::styled( + "Editor Mode", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + "Current input buffer:", + Style::default().fg(Theme::DIM), + )), + Line::from(Span::styled( + &state.input.buffer, + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + format!("Cursor at position {} / {}", state.input.cursor, state.input.buffer.len()), + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Effort => { + let block = block.title(" Effort Level "); + let levels = ["low", "medium", "high", "xhigh", "max"]; + let current_idx = if state.settings.temperature > 0.8 { 0usize } + else if state.settings.temperature > 0.5 { 1 } + else if state.settings.temperature > 0.3 { 2 } + else if state.settings.max_tokens > 4000 { 3 } + else { 4 }; + let mut lines: Vec = levels.iter().enumerate().map(|(i, l)| { + let selected = i == current_idx; + Line::from(Span::styled( + if selected { format!("> {} (current)", l) } else { format!(" {}", l) }, + if selected { + Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Theme::TEXT) + }, + )) + }).collect(); + lines.insert(0, Line::from(Span::styled( + "Use arrow keys to change effort level", + Style::default().fg(Theme::DIM), + ))); + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Mcp => { + let block = block.title(" MCP Servers "); + let lines: Vec = vec![ + Line::from(Span::styled( + "MCP Server Management", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + format!("Session dir: {}", state.session_dir.display()), + Style::default().fg(Theme::DIM), + )), + Line::from(Span::styled( + "No MCP servers configured.", + Style::default().fg(Theme::INFO), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + "Press Ctrl+P to configure provider settings.", + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Security => { + let block = block + .title(" Security ") + .border_style(Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::WARNING })); + let lines = vec![ + Line::from(Span::styled( + if state.misc.security_armed { + "SECURITY ESCALATION ACTIVE" + } else { + "Security Status: Normal" + }, + Style::default().fg(if state.misc.security_armed { Theme::ERROR } else { Theme::INFO }) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + format!("Armed: {}", state.misc.security_armed), + Style::default().fg(if state.misc.security_armed { Theme::WARNING } else { Theme::DIM }), + )), + Line::from(Span::styled( + format!("Esc presses: {}", state.misc.esc_press_count), + Style::default().fg(Theme::DIM), + )), + Line::from(Span::styled( + "Catastrophic-op guard: active in all modes", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + "Credential exfil patterns: monitored", + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + "Destructive git ops: blocked", + Style::default().fg(Theme::TEXT), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Todo => { + let block = block.title(" Tasks "); + let msg_count = state.transcript_cache.messages.len(); + let lines = vec![ + Line::from(Span::styled( + "Session Activity", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + Line::from(Span::styled( + format!("Messages: {}", msg_count), + Style::default().fg(Theme::INFO), + )), + Line::from(Span::styled( + format!("Mode: {}", state.mode.name()), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Overlay: {:?}", state.misc.overlay), + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Rewind => { + let block = block.title(" Rewind "); + let mut lines: Vec = vec![ + Line::from(Span::styled( + "Session History", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "", + Style::default(), + )), + ]; + let messages = &state.transcript_cache.messages; + if messages.is_empty() { + lines.push(Line::from(Span::styled( + "No messages in current session.", + Style::default().fg(Theme::DIM), + ))); + } else { + let start = if messages.len() > 5 { messages.len() - 5 } else { 0 }; + for msg in &messages[start..] { + let role_str = match msg.role { + crate::dto::chat::message::Role::User => "User", + crate::dto::chat::message::Role::Assistant => "Asst", + crate::dto::chat::message::Role::System => "Sys", + crate::dto::chat::message::Role::Tool => "Tool", + }; + let preview: String = msg.content.chars().take(60).collect(); + lines.push(Line::from(Span::styled( + format!("[{}] {}", role_str, preview), + Style::default().fg(if matches!(msg.role, crate::dto::chat::message::Role::User) { Theme::INFO } else { Theme::TEXT }), + ))); + } + if messages.len() > 5 { + lines.push(Line::from(Span::styled( + format!("... and {} more messages", messages.len() - 5), + Style::default().fg(Theme::DIM), + ))); + } + } + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Learning => { + let block = block.title(" Learning "); + let (total, by_user, by_fb, by_proj, by_ref, act, stale, contra, human, verified, unverified) = state.session_runtime.as_ref().map(|r| { + (r.lesson_count, r.lessons_user, r.lessons_feedback, r.lessons_project, r.lessons_reference, r.lessons_active, r.lessons_stale, r.lessons_contradicted, r.lessons_human, r.lessons_verified, r.lessons_unverified) + }).unwrap_or_default(); + let lines = vec![ + Line::from(Span::styled( + "Lessons Dashboard", + Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled( + format!("Total lessons: {}", total), + Style::default().fg(Theme::INFO), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("By Type:", Style::default().fg(Theme::DIM))), + Line::from(Span::styled( + format!(" User: {}", by_user), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Feedback: {}", by_fb), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Project: {}", by_proj), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Reference: {}", by_ref), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("Lifecycle:", Style::default().fg(Theme::DIM))), + Line::from(Span::styled( + format!(" Active: {}", act), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Stale: {}", stale), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Contradicted: {}", contra), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("Confidence:", Style::default().fg(Theme::DIM))), + Line::from(Span::styled( + format!(" Human: {}", human), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Verified: {}", verified), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Unverified: {}", unverified), + Style::default().fg(Theme::TEXT), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Usage => { + let block = block.title(" Usage "); + let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = state.session_runtime.as_ref().map(|r| { + (r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start) + }).unwrap_or((0, 0, 0, 0, 0)); + let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); + let hours = elapsed_ms / 3600000; + let minutes = (elapsed_ms % 3600000) / 60000; + let seconds = (elapsed_ms % 60000) / 1000; + let total_tokens = tokens_in.saturating_add(tokens_out); + let lines = vec![ + Line::from(Span::styled( + "Usage Dashboard", + Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled( + format!("Tokens in: {}", tokens_in), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Tokens out: {}", tokens_out), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Total tokens: {}", total_tokens), + Style::default().fg(Theme::INFO), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled( + format!("API calls: {}", api_calls), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!("Review tokens: {}", review_tokens), + Style::default().fg(Theme::INFO), + )), + Line::from(Span::styled( + format!("Session time: {}h {}m {}s", hours, minutes, seconds), + Style::default().fg(Theme::DIM), + )), + ]; + let paragraph = Paragraph::new(lines).block(block); + frame.render_widget(paragraph, overlay_area); + } + crate::app::state::types::Overlay::Loading => { + let block = block.title(" Loading "); + let content = "Processing, please wait..."; + let paragraph = Paragraph::new(content) + .block(block); + frame.render_widget(paragraph, overlay_area); + } + } +} + +fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + let block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Theme::BORDER)); + + let input_text = &state.input.buffer; + let cursor_pos = state.input.cursor; + + let prompt = Span::styled( + "> ", + Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), + ); + + let mut spans = vec![prompt]; + + if input_text.is_empty() { + spans.push(Span::styled( + "Type a message...", + Style::default().fg(Theme::DIM), + )); + } else { + let (before, after) = input_text.split_at(cursor_pos); + spans.push(Span::raw(before.to_string())); + spans.push(Span::styled( + if after.is_empty() { " " } else { &after[..1] }, + Style::default().bg(Theme::HIGHLIGHT).fg(Theme::BG), + )); + if after.len() > 1 { + spans.push(Span::raw(after[1..].to_string())); + } + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line).block(block); + frame.render_widget(paragraph, area); +} + +fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let x_pad = (area.width.saturating_sub(area.width * percent_x / 100)) / 2; + let y_pad = (area.height.saturating_sub(area.height * percent_y / 100)) / 2; + + Rect { + x: area.x.saturating_add(x_pad), + y: area.y.saturating_add(y_pad), + width: area.width.saturating_sub(x_pad * 2).max(40), + height: area.height.saturating_sub(y_pad * 2).max(10), + } +} diff --git a/src/view/status.rs b/src/view/status.rs new file mode 100644 index 0000000..1f2505d --- /dev/null +++ b/src/view/status.rs @@ -0,0 +1,54 @@ +use ratatui::layout::Rect; +use ratatui::style::{Style, Modifier}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Block; +use ratatui::Frame; +use super::theme::Theme; + +pub fn draw_status_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + let mode_str = state.mode.name(); + let mode_color = match state.mode { + crate::app::state::types::AgentMode::Auto => Theme::MODE_AUTO, + crate::app::state::types::AgentMode::Normal => Theme::MODE_NORMAL, + crate::app::state::types::AgentMode::Plan => Theme::MODE_PLAN, + crate::app::state::types::AgentMode::Yolo => Theme::MODE_YOLO, + }; + + let mode_indicator = if state.mode.auto_approve() { + "AUTO-APPROVE (UNRESTRICTED)".to_string() + } else { + format!("{}-APPROVE (RESTRICTED)", mode_str.to_uppercase()) + }; + + let session_count = state.sessions.len(); + let msg_count = state.transcript_cache.messages.len(); + + let left_text = Span::styled( + " [zesdex] ", + Style::default() + .fg(Theme::TEXT) + .add_modifier(Modifier::BOLD), + ); + + let center_text = Span::styled( + format!(" | STATUS: {} | PROTOCOL: ZERO-STUBS ACTIVE ", mode_indicator), + Style::default().fg(mode_color), + ); + + let right_text = Span::styled( + format!(" | MSG: {} | SESS: {} ", msg_count, session_count), + Style::default().fg(Theme::DIM), + ); + + let line = Line::from(vec![left_text, center_text, right_text]); + + let block = Block::default() + .style( + Style::default() + .bg(Theme::STATUS_BAR_BG) + .fg(Theme::TEXT), + ); + + let paragraph = ratatui::widgets::Paragraph::new(line).block(block); + frame.render_widget(paragraph, area); +} diff --git a/src/view/theme.rs b/src/view/theme.rs new file mode 100644 index 0000000..45e7694 --- /dev/null +++ b/src/view/theme.rs @@ -0,0 +1,26 @@ +use ratatui::style::Color; + +pub struct Theme; + +impl Theme { + pub const PRIMARY: Color = Color::Cyan; + pub const SECONDARY: Color = Color::Magenta; + pub const SUCCESS: Color = Color::Green; + pub const WARNING: Color = Color::Yellow; + pub const ERROR: Color = Color::Red; + pub const INFO: Color = Color::Blue; + pub const TEXT: Color = Color::White; + pub const DIM: Color = Color::DarkGray; + pub const HIGHLIGHT: Color = Color::LightYellow; + pub const BORDER: Color = Color::Gray; + pub const ROLE_USER: Color = Color::Green; + pub const ROLE_ASSISTANT: Color = Color::Cyan; + pub const ROLE_SYSTEM: Color = Color::Blue; + pub const ROLE_TOOL: Color = Color::Yellow; + pub const BG: Color = Color::Reset; + pub const STATUS_BAR_BG: Color = Color::Blue; + pub const MODE_AUTO: Color = Color::Green; + pub const MODE_NORMAL: Color = Color::Yellow; + pub const MODE_PLAN: Color = Color::Cyan; + pub const MODE_YOLO: Color = Color::Red; +} diff --git a/src/view/workflow.rs b/src/view/workflow.rs new file mode 100644 index 0000000..40362c8 --- /dev/null +++ b/src/view/workflow.rs @@ -0,0 +1,86 @@ +use ratatui::layout::Rect; +use ratatui::style::{Style, Modifier}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem}; +use ratatui::Frame; +use super::theme::Theme; + +pub fn draw_workflow_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::BORDER)) + .title(" Workflow "); + + let session_runtime = match &state.session_runtime { + Some(r) => r, + None => { + let empty = Block::default().borders(Borders::ALL).title(" Workflow "); + let paragraph = ratatui::widgets::Paragraph::new(Line::from(Span::raw("No active session"))) + .block(empty); + frame.render_widget(paragraph, area); + return; + } + }; + + let tool_count = session_runtime.tool_call_results.len(); + let pending_count = session_runtime.pending_tool_queue.len(); + let bash_count = session_runtime.bash_jobs.len(); + let subagent_queue = session_runtime.subagent_queue; + let edit_count = session_runtime.edit_count; + + let mut items = Vec::new(); + + items.push(ListItem::new(Line::from(Span::styled( + format!(" Messages: {}", session_runtime.messages.len()), + Style::default().fg(Theme::TEXT), + )))); + + items.push(ListItem::new(Line::from(Span::styled( + format!(" Tool calls completed: {}", tool_count), + Style::default().fg(Theme::SUCCESS), + )))); + + if pending_count > 0 { + items.push(ListItem::new(Line::from(Span::styled( + format!(" Pending tool queue: {}", pending_count), + Style::default().fg(Theme::WARNING), + )))); + } + + if bash_count > 0 { + items.push(ListItem::new(Line::from(Span::styled( + format!(" Active bash jobs: {}", bash_count), + Style::default().fg(Theme::WARNING), + )))); + } + + if subagent_queue > 0 { + items.push(ListItem::new(Line::from(Span::styled( + format!(" Subagent queue: {}", subagent_queue), + Style::default().fg(Theme::INFO), + )))); + } + + items.push(ListItem::new(Line::from(Span::styled( + format!(" Edits: {}", edit_count), + Style::default().fg(Theme::INFO), + )))); + + let phase_status = match state.mode { + crate::app::state::types::AgentMode::Auto => "Auto-running", + crate::app::state::types::AgentMode::Normal => "Awaiting input", + crate::app::state::types::AgentMode::Plan => "Planning", + crate::app::state::types::AgentMode::Yolo => "Full autonomy", + }; + + items.push(ListItem::new(Line::from(Span::styled( + format!(" Phase: {}", phase_status), + Style::default().fg(Theme::PRIMARY), + )))); + + let list = List::new(items) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + + frame.render_widget(list, area); +}