From ef58a88e8cd773008a8c0f4783a8328af813e622 Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Sun, 21 Jun 2026 09:58:05 -0500 Subject: [PATCH] Inital commit --- .gitignore | 1 + Cargo.lock | 2603 ++++++++++++++++++++++++++++ Cargo.toml | 43 + PLAN.md | 544 ++++++ README.md | 77 + src/access.rs | 41 + src/agent.rs | 176 ++ src/app.rs | 448 +++++ src/bin/cassady.rs | 4 + src/cli.rs | 38 + src/config.rs | 121 ++ src/conversation.rs | 230 +++ src/error.rs | 13 + src/lib.rs | 15 + src/main.rs | 4 + src/prompt.rs | 46 + src/providers/mod.rs | 2 + src/providers/openai_compatible.rs | 188 ++ src/providers/types.rs | 29 + src/tools/edit.rs | 84 + src/tools/grep.rs | 151 ++ src/tools/ls.rs | 80 + src/tools/mod.rs | 90 + src/tools/path.rs | 57 + src/tools/read.rs | 101 ++ src/tools/schema.rs | 10 + src/tools/write.rs | 54 + src/ui/events.rs | 10 + src/ui/mod.rs | 4 + src/ui/render.rs | 274 +++ src/ui/terminal.rs | 29 + src/ui/theme.rs | 21 + tests/conversation_tests.rs | 25 + tests/tool_tests.rs | 94 + 34 files changed, 5707 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 src/access.rs create mode 100644 src/agent.rs create mode 100644 src/app.rs create mode 100644 src/bin/cassady.rs create mode 100644 src/cli.rs create mode 100644 src/config.rs create mode 100644 src/conversation.rs create mode 100644 src/error.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/prompt.rs create mode 100644 src/providers/mod.rs create mode 100644 src/providers/openai_compatible.rs create mode 100644 src/providers/types.rs create mode 100644 src/tools/edit.rs create mode 100644 src/tools/grep.rs create mode 100644 src/tools/ls.rs create mode 100644 src/tools/mod.rs create mode 100644 src/tools/path.rs create mode 100644 src/tools/read.rs create mode 100644 src/tools/schema.rs create mode 100644 src/tools/write.rs create mode 100644 src/ui/events.rs create mode 100644 src/ui/mod.rs create mode 100644 src/ui/render.rs create mode 100644 src/ui/terminal.rs create mode 100644 src/ui/theme.rs create mode 100644 tests/conversation_tests.rs create mode 100644 tests/tool_tests.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d9bb882 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2603 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[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", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[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 = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cassady" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "crossterm", + "dirs", + "futures-util", + "ignore", + "nanoid", + "ratatui", + "regex", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tui-textarea", + "unicode-width", + "wiremock", +] + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[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.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[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 = "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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "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 = "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", +] + +[[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", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[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", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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", +] + +[[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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[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 = "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.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[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", + "httpdate", + "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", + "webpki-roots", +] + +[[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", + "tokio", + "tower-service", + "tracing", +] + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[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 = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[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 = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[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 = "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 = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[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 = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.6", +] + +[[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_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +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", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[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 = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "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", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_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 = "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 = "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 = "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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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", +] + +[[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 = "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", +] + +[[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", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[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", + "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", +] + +[[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-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", + "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-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-textarea" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c07084342a575cea919eea996b9658a358c800b03d435df581c1d7c60e065a" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width", +] + +[[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 = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[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 = "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.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +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 = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[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-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-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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 = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[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 = "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", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "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", +] + +[[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..dc7df8c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "cassady" +version = "0.1.0" +edition = "2021" +description = "Cassady/Cass minimal terminal coding agent" +license = "MIT" +default-run = "cass" + +[lib] +name = "cassady" +path = "src/lib.rs" + +[[bin]] +name = "cass" +path = "src/main.rs" + +[[bin]] +name = "cassady" +path = "src/bin/cassady.rs" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4", features = ["derive"] } +crossterm = "0.28" +dirs = "5" +futures-util = "0.3" +ignore = "0.4" +nanoid = "0.4" +ratatui = "0.28" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time"] } +tui-textarea = "0.6" +unicode-width = "0.1" + +[dev-dependencies] +tempfile = "3" +wiremock = "0.6" diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..7a05648 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,544 @@ +# Cassady (Cass) Implementation Plan + +Implementation status: v1 MVP implemented in this repository. It includes the Rust crate, `cass` and `cassady` binaries, JSONL persistence, read-only/full-access modes, `ls`/`read`/`grep`/`write`/`edit` tools, atomic writes for mutating tools, OpenAI-compatible provider support with Fireworks defaults, and a ratatui/crossterm chat UI. + +## 1. Goal and Scope + +Cassady, or Cass, is a minimal Rust terminal coding agent with a looped chat interface. The installed command should be available as both `cass` and `cassady`. The interface lets a user type messages, send them to an LLM-backed agent, and receive streamed assistant output. + +Cass starts in read-only mode by default. In read-only mode, filesystem inspection is restricted to the directory where the CLI was launched, or to the directory supplied with `--cwd`. Cass can be toggled into full-access mode by pressing `Shift-Tab`, but only while the agent is idle and not processing a turn. In full-access mode, Cass may access normal filesystem paths available to the user and may use mutating tools. + +The agent stores configuration, global instructions, and conversations under `~/.cass/`. Conversations are persisted as JSONL so a previous chat can be resumed with `--resume `. Running `--resume` without a chat id lists past chats for the current directory. Stored conversation data should contain only the data needed to reconstruct context: system prompt, user messages, assistant messages, tool calls, and tool results. + +Cass ships with a deliberately small filesystem tool set: `ls`, `read`, `grep`, `write`, and `edit`. In read-only mode, only `ls`, `read`, and `grep` are available. In full-access mode, all tools are available. There is no shell/bash command tool in v1. + +## 2. Product Behavior + +The CLI opens into an interactive terminal chat session. It shows the current chat id, current access mode, working directory, and a single user input area. The UI should feel clean and modern without becoming a complex full IDE-like TUI. + +The default access mode is read-only. Pressing `Shift-Tab` toggles between read-only and full-access mode only when Cass is idle. If the model is currently streaming, executing tools, or otherwise processing a user turn, `Shift-Tab` should do nothing or show a small status hint such as `mode can be changed when idle`. + +Exiting requires pressing `Ctrl-C` twice within 1.5 seconds. The first `Ctrl-C` clears the current user input box and shows a short hint that another `Ctrl-C` will exit. If the second `Ctrl-C` is not received within 1.5 seconds, the exit intent expires and later `Ctrl-C` presses again only clear the input. When Cass exits a chat this way, it should print a resume hint after restoring the terminal, exactly in this shape: `Resume this chat with: cass --resume `. + +During a turn, Cass sends the conversation context and system prompt to the model. Assistant message blocks stream line by line as they arrive. Tool calls are displayed live, including tool name and arguments. When each tool result arrives, Cass displays the result beneath the corresponding tool call. Tool results are truncated by default; pressing `Ctrl-O` toggles between truncated and full tool-call/tool-result display. + +## 3. Project Structure + +Create a small Rust workspace or single crate with a focused module layout. + +```text +cass/ + Cargo.toml + README.md + PLAN.md + src/ + main.rs + cli.rs + app.rs + config.rs + conversation.rs + agent.rs + prompt.rs + access.rs + error.rs + tools/ + mod.rs + schema.rs + ls.rs + read.rs + grep.rs + write.rs + edit.rs + path.rs + ui/ + mod.rs + terminal.rs + events.rs + render.rs + theme.rs + providers/ + mod.rs + types.rs + openai_compatible.rs + tests/ + conversation_tests.rs + tool_read_tests.rs + tool_grep_tests.rs + tool_edit_tests.rs + access_mode_tests.rs + fixtures/ +``` + +Keep modules focused: conversation persistence should not know about terminal rendering, tools should not know about model providers, and the agent loop should coordinate model calls, tool execution, streaming events, and persistence. + +## 4. Rust Libraries + +Use stable Rust, preferably the latest stable toolchain. + +Recommended runtime libraries: + +- `tokio` for async runtime, streaming model responses, and non-blocking UI coordination. +- `ratatui` plus `crossterm` for a clean terminal UI with panes, status bars, key handling, and rendering control. +- `tui-textarea` or a small custom input widget for multiline input editing. +- `clap` for CLI argument parsing. +- `serde` and `serde_json` for config, provider payloads, tool arguments, and JSONL conversation records. +- `reqwest` with streaming support for OpenAI-compatible API requests. +- `schemars` or handwritten JSON schemas for tool definitions. +- `regex`, `walkdir`, and optionally `ignore` for the `grep` tool, recursive search, `.gitignore` handling, and binary-file skipping. +- `anyhow` for application-level errors and `thiserror` for structured module errors. +- `chrono` or `time` for timestamps. +- `uuid`, `rand`, or `nanoid` for the random suffix in chat ids. +- `dirs` only for resolving the home directory; the storage path remains fixed at `~/.cass/`. +- `similar` can be considered later for nicer edit previews, but exact replacement is enough for v1. + +Recommended development libraries: + +- `tempfile` for filesystem tool tests. +- `insta` for snapshot-testing rendered text or JSON records if useful. +- `wiremock` or a lightweight mock provider for integration tests. + +## 5. Configuration and Storage + +Cass uses `~/.cass/` as the root directory. + +```text +~/.cass/ + config.json + global.md + conversations/ + .jsonl +``` + +`config.json` stores provider configuration and defaults: + +```json +{ + "provider": "openai-compatible", + "model": "accounts/fireworks/models/qwen3p7-plus", + "base_url": "https://api.fireworks.ai/inference/v1", + "api_key_env": "FIREWORKS_API_KEY", + "default_access_mode": "read-only" +} +``` + +The app should create `~/.cass/` and `~/.cass/conversations/` on first run. It may create an empty or commented `~/.cass/global.md` if missing. It should not create a default API key file. API keys should come from environment variables. + +Chat ids are timestamp-based plus a short random suffix, for example `2026-06-20-143012-a8f3`. `--resume ` loads `~/.cass/conversations/.jsonl` and rebuilds the conversation context. `--resume` without a chat id lists chats whose stored `cwd` matches the current launch directory or `--cwd`. + +## 6. JSONL Conversation Format + +Each line is one JSON object. This keeps appends simple and makes corrupted tail recovery easier. + +Recommended event records: + +```json +{"type":"meta","chat_id":"...","created_at":"...","model":"accounts/fireworks/models/qwen3p7-plus","cwd":"..."} +{"type":"system","content":"..."} +{"type":"message","role":"user","content":"...","created_at":"..."} +{"type":"message","role":"assistant","content":"...","tool_calls":[...],"created_at":"..."} +{"type":"tool_result","tool_call_id":"...","name":"read","content":"...","ok":true,"created_at":"..."} +``` + +When reconstructing context for the model, convert the JSONL records into provider-specific message objects. The stored data should not include UI-only information such as cursor position, terminal size, collapsed/expanded display state, transient status banners, or keypress history. + +The conversation should store the stable base system prompt for that chat, including the contents of `~/.cass/global.md` at chat creation time. Dynamic runtime details such as current access mode, currently allowed tools, and current filesystem boundary should be injected when building the provider request for each turn, so toggling `Shift-Tab` changes the effective instructions immediately. This keeps resumed conversations reproducible while avoiding a stale read-only/full-access prompt after mode changes. + +## 7. System Prompt Shape + +The system prompt should be hardcoded in `src/prompt.rs` and written as paragraph-separated numbered sections, not as dense bullet lists. It should describe Cassady's role, access-mode constraints, read-only workspace limits, tool-use expectations, streaming expectations, and editing rules. + +`~/.cass/global.md` provides user-specific extra instructions. Cass should insert the contents of `global.md` near the top-middle of the system prompt, after the identity/operating-style section and before the detailed access-mode and tool rules. If `global.md` is empty or missing, omit that section cleanly. + +Example structure: + +```text +1. Identity and operating style + +You are Cassady, a minimal coding agent running in a terminal chat interface. Work carefully, explain concise next steps, and prefer inspecting files before changing them. + +2. User global instructions + +The following additional instructions were provided by the user and should be followed when they do not conflict with runtime safety constraints: + + + +3. Access modes and filesystem boundaries + +You are operating under an access mode supplied by the runtime. In read-only mode you may inspect files with ls, read, and grep only inside the launch working directory. In full-access mode you may request write and edit when needed and paths are not restricted to the launch directory by Cass. + +4. Reading and searching files + +When reading context, batch related reads into a single read tool call. The read tool accepts multiple files and optional line ranges per file, so prefer one well-scoped call over many small calls. When a file or directory may be too large to read directly, use grep to locate relevant lines before reading narrower ranges. + +5. Editing files + +Use edit for targeted changes. Each edit must identify exact old text that appears uniquely in the file and replacement text. Do not use write to make small modifications to existing files unless a full rewrite is explicitly intended. +``` + +The actual prompt builder should receive the stored base prompt/global instructions plus current access mode, launch working directory, current unrestricted/full-access status, model name, and allowed tool list. It should rebuild the effective provider system prompt on every turn. + +## 8. Access Modes and Path Boundaries + +Define an `AccessMode` enum: + +```rust +enum AccessMode { + ReadOnly, + FullAccess, +} +``` + +Mode behavior: + +- `ReadOnly` permits `ls`, `read`, and `grep` only. +- `ReadOnly` restricts all tool paths to the launch working directory or the path supplied by `--cwd`. +- `FullAccess` permits `ls`, `read`, `grep`, `write`, and `edit`. +- `FullAccess` does not restrict paths to the launch working directory, though tools still operate under the user's OS permissions. + +Tool enforcement must happen in code, not only in the system prompt. If a model requests a disallowed tool, Cass returns a tool result explaining that the tool is unavailable in the current mode and asks the model to continue within constraints. + +Path enforcement should be centralized in `tools/path.rs`. In read-only mode, resolve symlinks/canonical paths where possible and reject paths escaping the allowed root. For paths that do not exist, read-only tools should generally fail because they only inspect existing files/directories. In full-access mode, expand `~` and resolve relative paths against the current working directory without applying the read-only root restriction. + +## 9. Tool Interfaces + +Represent each tool with: + +- name +- description +- JSON schema for arguments +- Rust executor function +- access mode requirement + +The provider adapter converts these definitions into OpenAI-compatible tool schema format. + +### `ls` + +Arguments: + +```json +{"path":"."} +``` + +Behavior: + +- Expands `~`. +- Resolves relative paths against the current working directory. +- In read-only mode, rejects paths outside the launch root. +- Returns a compact directory listing with directories clearly marked. +- Does not recursively list by default. +- Fails cleanly if the path does not exist or is not a directory. + +### `read` + +Arguments should support multiple files in one call: + +```json +{ + "files": [ + {"path":"README.md"}, + {"path":"src/app.rs","lines":"35-60"} + ] +} +``` + +Behavior: + +- Expands `~` and resolves relative paths against the current working directory. +- In read-only mode, rejects paths outside the launch root. +- Allows optional 1-indexed inclusive line ranges like `35-60`, `35-`, or `-60`. +- Returns each file with a header containing path and selected line range. +- Adds line numbers in output. +- Applies output limits per file and overall to avoid oversized context. +- Treats binary files as unsupported unless later image or binary support is explicitly added. + +### `grep` + +Arguments should support searching a single file, multiple files, or directories: + +```json +{ + "query": "function main", + "paths": ["src", "README.md"], + "regex": false, + "case_sensitive": false, + "context_lines": 2, + "max_matches": 100 +} +``` + +Behavior: + +- Expands `~` and resolves relative paths against the current working directory. +- In read-only mode, rejects paths outside the launch root. +- Searches files directly and walks directories recursively. +- Skips common heavy directories such as `.git`, `target`, `node_modules`, and vendor/cache folders by default. +- Respects `.gitignore` when using the `ignore` crate, unless a later option disables that behavior. +- Supports literal search by default and optional regex search. +- Returns matching path, line number, matching line, and optional surrounding context lines. +- Applies match-count and output-size limits so the model can search large projects without reading whole files into context. +- Treats binary files as unsupported/skipped. + +### `write` + +Arguments: + +```json +{ + "path":"path/to/file.txt", + "content":"new full file contents" +} +``` + +Behavior: + +- Requires full-access mode. +- Expands `~` and resolves relative paths against the current working directory. +- Creates parent directories as needed. +- Overwrites existing files or creates new files. +- Writes atomically where practical by writing to a temporary file in the destination directory and renaming it into place. Atomic writing means Cass avoids leaving a half-written destination file if the process crashes or the disk write fails midway. +- Returns a concise success result with bytes written. + +### `edit` + +Arguments: + +```json +{ + "path":"path/to/file.rs", + "edits":[ + {"old_text":"exact existing text","new_text":"replacement text"} + ] +} +``` + +Behavior: + +- Requires full-access mode. +- Reads the original file once. +- Every `old_text` must match exactly one unique non-overlapping region in the original file. +- All edits are validated against the original content before writing anything. +- If any edit fails because text is missing, duplicated, or overlapping, no changes are written. +- If validation passes, apply all replacements and write the file atomically where practical, using the same temporary-file-and-rename approach as `write`. +- Return a concise result with number of edits applied. + +This mirrors the safe exact-replacement behavior used by coding-agent edit tools. + +## 10. Agent Loop + +The agent loop should follow this flow: + +1. Load or create the conversation. +2. Build the effective system prompt from the stored base prompt plus current access mode, launch working directory, allowed tools, and model name. +3. Append the user's message to JSONL. +4. Send context, system prompt, and allowed tools to the provider. +5. Stream assistant text blocks line by line to the UI and append the final assistant message when complete. +6. If the assistant emits tool calls, stream the tool call name and arguments to the UI, append the assistant tool-call message, execute each tool, stream each tool result under its call, append each result, and call the model again. +7. Continue until a final assistant message is produced or a max tool-iteration limit is reached. + +Use a conservative max tool-iteration limit, such as 8 per user turn, to prevent runaway loops. + +The agent should emit internal UI events rather than writing directly to the terminal. Example events: + +```rust +enum AgentEvent { + AssistantLine(String), + ToolCallStarted { id: String, name: String, arguments: serde_json::Value }, + ToolResult { id: String, name: String, ok: bool, content: String }, + Status(String), + TurnFinished, +} +``` + +This lets the UI render streaming output, truncate or expand tool results with `Ctrl-O`, and keep the agent loop testable. + +### Context and tool-result limits + +For v1, Cass should keep context management simple. Reconstruct the conversation from JSONL and send recent context that fits within configured limits. If the conversation becomes too large, prefer dropping oldest non-system messages from the provider request and keep the full JSONL on disk. If the provider still rejects the request as too large, show a clear `context too large` error and keep the chat open. + +Tool results have two separate limits. The model-facing result should be large enough to be useful but capped to avoid exhausting context; large reads should recommend using `grep` or narrower line ranges. The UI-facing result is truncated more aggressively by default and can be expanded with `Ctrl-O`. Display truncation must not change what is stored in JSONL or what is sent back to the model. + +## 11. Provider Abstraction + +Start with one provider implementation: a general OpenAI-compatible chat completions client. The default configuration points at Fireworks, but the code should work with other OpenAI-compatible endpoints when `base_url`, model, and API key environment variable are configured. + +Provider interface: + +```rust +#[async_trait::async_trait] +trait ChatProvider { + async fn stream_complete( + &self, + messages: Vec, + tools: Vec, + ) -> Result; +} +``` + +The OpenAI-compatible implementation reads: + +- model from CLI args, config, or default, defaulting to `accounts/fireworks/models/qwen3p7-plus` +- base URL from CLI args, config, or default, defaulting to `https://api.fireworks.ai/inference/v1` +- API key environment variable from CLI args, config, or default, defaulting to `FIREWORKS_API_KEY` + +Configuration precedence is: CLI args first, then `~/.cass/config.json`, then built-in defaults. Normalize `base_url` so users can provide either the API root or the full chat-completions URL without accidentally producing a doubled path. + +Avoid tying the rest of the app to raw provider response shapes. Convert streaming deltas and final responses into internal assistant text, tool-call, and completion events. Because OpenAI-compatible endpoints vary, support streamed assistant text as the primary path and handle tool calls whether they arrive as streamed deltas or only in a final assistant message. + +## 12. CLI Arguments + +Initial CLI: + +```text +cass [--resume [CHAT_ID]] [--model MODEL] [--base-url URL] [--api-key-env ENV] [--cwd PATH] +cassady [--resume [CHAT_ID]] [--model MODEL] [--base-url URL] [--api-key-env ENV] [--cwd PATH] +``` + +Behavior: + +- `--resume CHAT_ID`: loads an existing conversation JSONL file. +- `--resume` without a chat id: lists past chats whose stored `cwd` matches the current `--cwd`/launch directory, showing chat id, creation time, model, and first user-message preview, then exits. +- `--model MODEL`: overrides configured model for the session. +- `--base-url URL`: overrides configured provider base URL. +- `--api-key-env ENV`: overrides configured API key environment variable for the session. +- `--cwd PATH`: sets the launch root and working directory for relative tool paths. Default is the shell's current working directory. + +Later optional flags: + +- `--config PATH` +- `--readonly` +- `--full-access` +- `--list-chats` +- `--unsafe-global-readonly` if a future mode is needed to disable read-only root restriction. + +Do not add slash commands for v1. Keep v1 key-driven plus CLI flags. + +## 13. UI Implementation + +Use `ratatui` and `crossterm` first. This gives enough control to create a polished terminal experience like Pi without requiring a complicated application architecture. + +Suggested layout: + +- Header/status bar: app name, chat id, model, access mode, cwd. +- Transcript area: user messages, streamed assistant lines, tool calls, and tool results. +- Input area: multiline user message editor. +- Footer/help bar: `Shift-Tab mode`, `Ctrl-J newline`, `Ctrl-O tool output`, `Ctrl-C clear/exit`. + +Key handling: + +- `Shift-Tab`: toggles access mode only when the agent is idle. +- `Ctrl-J`: inserts a newline into the user input. +- `Ctrl-O`: toggles tool output display between truncated and full. +- `Ctrl-C`: first press clears input and arms exit; second press within 1.5 seconds exits and prints `Resume this chat with: cass --resume ` after terminal cleanup. +- `Enter`: sends the message if the input is non-empty. + +Streaming display: + +- Assistant message blocks stream line by line. +- Tool calls render as visible blocks with tool name and formatted JSON arguments. +- Tool results render directly beneath their tool call. +- Tool results are truncated by default with an indicator such as `… truncated, press Ctrl-O to expand`. +- The `Ctrl-O` state is UI-only and must not be persisted in conversation JSONL. + +Transcript navigation should optimize for a clean, user-friendly experience. Auto-scroll while new output streams if the user is already at the bottom; preserve the user's scroll position if they have scrolled up. Support mouse wheel and `PageUp`/`PageDown` for transcript scrollback, handle terminal resize without corrupting layout, and keep the input box visible. + +## 14. Error Handling + +Tool errors should be returned to the model as normal tool results with `ok: false`, not crash the app. Provider errors should show a concise user-facing message and keep the conversation open. Conversation append failures should be treated as serious and displayed clearly because persistence is core behavior. + +For malformed JSONL during resume, load valid records until the first corrupted line and warn the user. Do not silently drop data. + +If the configured API key environment variable is missing, show a clear setup error indicating which variable must be set, while still allowing the user to exit cleanly. With default settings, this variable is `FIREWORKS_API_KEY`. + +## 15. Testing Plan + +Unit tests: + +- Conversation JSONL append and reload. +- Resume with missing chat id. +- `--resume` without a chat id lists chats for the current cwd. +- Resume with partially corrupted JSONL. +- Effective system prompt includes hardcoded prompt and inserts `global.md` near the top-middle. +- Access-mode tool filtering. +- Disallowed tool-call handling. +- Read-only path restriction rejects paths outside `--cwd`, including symlink escapes. +- Full-access path resolution does not apply the read-only root restriction. +- `ls` path resolution and error behavior. +- `read` with full file, bounded ranges, open-ended ranges, multiple files, missing file, binary file, and path outside read-only root. +- `grep` with literal search, regex search, case sensitivity, directory recursion, max match limits, skipped binary files, ignored heavy directories, and path outside read-only root. +- `write` creates parent directories, overwrites content, uses atomic temp-file behavior where practical, and is unavailable in read-only mode. +- `edit` applies single edit, multiple disjoint edits, rejects missing text, rejects duplicate old text, rejects overlapping edits, preserves file when validation fails, and is unavailable in read-only mode. + +Integration tests: + +- Mock provider streams a final assistant message. +- Mock provider requests `read`, receives streamed tool output, then returns final answer. +- Mock provider requests `grep`, receives search matches, then requests a narrower `read`. +- Mock provider requests disallowed `write` in read-only mode. +- Mock provider requests a path outside the read-only root. +- Resume conversation and continue with previous context. + +Manual tests: + +- Start a new chat with `cass` and `cassady`. +- Press `Shift-Tab` while idle and confirm mode toggles. +- Press `Shift-Tab` while the agent is running and confirm mode does not change. +- Press `Ctrl-J` and confirm it inserts a newline rather than sending. +- Press `Ctrl-O` and confirm tool results expand/collapse. +- Press `Ctrl-C` once and confirm input clears. +- Press `Ctrl-C` twice within 1.5 seconds and confirm exit. +- Confirm exit prints `Resume this chat with: cass --resume `. +- Verify assistant output streams line by line. +- Verify tool calls and truncated tool results display live. +- Create a file in full-access mode using `write`. +- Modify a file in full-access mode using `edit`. + +## 16. Implementation Milestones + +### Milestone 1: Rust skeleton and persistence + +Set up `Cargo.toml`, module layout, CLI argument parsing, config loading, `~/.cass/` initialization, `global.md` loading, chat id generation, JSONL append, and resume loading. + +### Milestone 2: Tools and access control + +Implement `ls`, `read`, `grep`, `write`, and `edit` with strong validation and tests. Add access-mode filtering, read-only root restriction, full-access unrestricted behavior, and disallowed-tool handling. + +### Milestone 3: Provider and agent loop + +Implement the provider abstraction, general OpenAI-compatible streaming provider with Fireworks defaults, message conversion, tool-call loop, max tool-iteration guard, context limiting, and system prompt generation. + +### Milestone 4: Terminal UI + +Implement the `ratatui`/`crossterm` interface, transcript rendering, input box, `Shift-Tab` mode toggle while idle only, `Ctrl-J` newline input, `Ctrl-O` tool output expansion, streaming assistant lines, streaming tool calls/results, scrollback behavior, resize handling, and double-`Ctrl-C` exit behavior. + +### Milestone 5: Polish and documentation + +Add README usage instructions, configuration examples, OpenAI-compatible endpoint setup, Fireworks API key setup, test coverage, linting, formatting, and packaging so users can install both `cass` and `cassady` commands with `cargo install --path .`. + +## 17. Resolved Product Decisions + +- The implementation language is Rust, not Python. +- The installed command should be both `cass` and `cassady`. +- The provider implementation should support general OpenAI-compatible endpoints. +- The default configuration points at Fireworks. +- The default model is `accounts/fireworks/models/qwen3p7-plus`. +- The default API key environment variable is `FIREWORKS_API_KEY`. +- Read-only mode restricts paths to the launch working directory or `--cwd`. +- Full-access mode does not apply the read-only path restriction. +- `Shift-Tab` only toggles mode while the agent is idle. +- `Ctrl-J` inserts a newline and `Enter` sends. +- Chat ids use timestamp plus random suffix. +- `--resume` without a chat id lists past chats for the current directory. +- The system prompt is hardcoded and can be augmented by `~/.cass/global.md` inserted near the top-middle. +- Assistant message blocks stream line by line. +- Tool calls and tool results are displayed live. +- Tool results are truncated by default and can be expanded/collapsed with `Ctrl-O`. +- v1 has no shell/bash tool. +- v1 has no slash commands. +- v1 uses atomic writes where practical for `write` and `edit`, by writing to a temporary file first and renaming it into place after validation/write success. +- v1 installs with `cargo install --path .`. + +## 18. Remaining Open Decisions + +- Exact numeric truncation limits for displayed tool results and model-facing tool results. +- Exact context-window budget defaults for OpenAI-compatible providers that may not report limits consistently. +- Exact default `grep` ignore rules and maximum recursive search size. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bda361 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Cassady / Cass + +Cassady (`cass`) is a minimal Rust terminal coding agent with a looped chat UI, filesystem tools, access modes, JSONL conversation persistence, and OpenAI-compatible LLM support. The default endpoint is Fireworks. + +## Install + +```sh +cargo install --path . +``` + +This installs both commands: + +```sh +cass +cassady +``` + +## Configure + +By default Cass uses: + +- base URL: `https://api.fireworks.ai/inference/v1` +- model: `accounts/fireworks/models/qwen3p7-plus` +- API key env var: `FIREWORKS_API_KEY` + +Set your key: + +```sh +export FIREWORKS_API_KEY=... +``` + +Optional config lives at `~/.cass/config.json`: + +```json +{ + "provider": "openai-compatible", + "model": "accounts/fireworks/models/qwen3p7-plus", + "base_url": "https://api.fireworks.ai/inference/v1", + "api_key_env": "FIREWORKS_API_KEY", + "default_access_mode": "read-only" +} +``` + +Extra global instructions can be placed in `~/.cass/global.md`. + +## Usage + +```sh +cass [--model MODEL] [--base-url URL] [--api-key-env ENV] [--cwd PATH] +cass --resume +cass --resume +``` + +`cass --resume` without an ID lists chats for the current directory. + +## Keys + +- `Enter`: send message +- `Ctrl-J`: insert newline +- `Shift-Tab`: toggle read-only/full-access mode while idle +- `Ctrl-O`: toggle truncated/full tool output display +- `PageUp`/`PageDown` or mouse wheel: transcript scroll +- `Ctrl-C` twice within 1.5 seconds: exit + +On exit Cass prints: + +```text +Resume this chat with: cass --resume +``` + +## Tools + +Read-only mode allows `ls`, `read`, and `grep` within the launch cwd/`--cwd`. + +Full-access mode additionally allows `write` and `edit`. Mutating tools use atomic writes where practical: Cass writes to a temporary file first, then renames it into place after validation/write success. + +There is no shell/bash tool in v1. diff --git a/src/access.rs b/src/access.rs new file mode 100644 index 0000000..5d02fb3 --- /dev/null +++ b/src/access.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum AccessMode { + ReadOnly, + FullAccess, +} + +impl Default for AccessMode { + fn default() -> Self { + Self::ReadOnly + } +} + +impl AccessMode { + pub fn toggle(self) -> Self { + match self { + Self::ReadOnly => Self::FullAccess, + Self::FullAccess => Self::ReadOnly, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "read-only", + Self::FullAccess => "full-access", + } + } + + pub fn can_write(self) -> bool { + matches!(self, Self::FullAccess) + } +} + +impl fmt::Display for AccessMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..569ce24 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,176 @@ +use crate::access::AccessMode; +use crate::config::Config; +use crate::conversation::{now_ts, Conversation, Record, StoredToolCall}; +use crate::prompt; +use crate::providers::openai_compatible::OpenAiCompatibleProvider; +use crate::providers::types::ModelMessage; +use crate::tools::{self, ToolContext}; +use anyhow::Result; +use serde_json::Value; +use std::path::PathBuf; +use tokio::sync::mpsc; + +#[derive(Debug, Clone)] +pub enum AgentEvent { + AssistantChunk(String), + ToolCallStarted { + id: String, + name: String, + arguments: Value, + }, + ToolResult { + id: String, + name: String, + ok: bool, + content: String, + }, + Status(String), + TurnFinished, +} + +#[derive(Debug, Clone)] +pub struct AgentSettings { + pub config: Config, + pub cwd: PathBuf, + pub mode: AccessMode, +} + +pub async fn run_turn( + mut conversation: Conversation, + user_message: String, + settings: AgentSettings, + tx: mpsc::UnboundedSender, +) -> Result { + conversation.append(Record::User { + content: user_message, + ts: now_ts(), + })?; + + let provider = match OpenAiCompatibleProvider::new( + settings.config.model.clone(), + settings.config.base_url.clone(), + settings.config.api_key_env.clone(), + ) { + Ok(p) => p, + Err(err) => { + let _ = tx.send(AgentEvent::Status(err.to_string())); + let _ = tx.send(AgentEvent::TurnFinished); + return Ok(conversation); + } + }; + + let tool_ctx = ToolContext { + mode: settings.mode, + cwd: settings.cwd.clone(), + read_only_root: settings.cwd.clone(), + model_result_limit: settings.config.model_tool_result_limit, + }; + + for _ in 0..8 { + let allowed = tools::available_tool_names(settings.mode); + let system = prompt::build_effective_system_prompt( + &conversation.base_system_prompt(), + settings.mode, + &settings.cwd, + &settings.config.model, + &allowed, + ); + let messages = build_messages( + &conversation.records, + system, + settings.config.context_message_limit, + ); + let completion = match provider + .complete(messages, tools::specs(settings.mode), &tx) + .await + { + Ok(c) => c, + Err(err) => { + let _ = tx.send(AgentEvent::Status(format!("provider error: {err}"))); + break; + } + }; + + let tool_calls = completion.tool_calls.clone(); + conversation.append(Record::Assistant { + content: completion.content, + tool_calls: tool_calls.clone(), + ts: now_ts(), + })?; + + if tool_calls.is_empty() { + break; + } + for call in tool_calls { + let _ = tx.send(AgentEvent::ToolCallStarted { + id: call.id.clone(), + name: call.name.clone(), + arguments: call.arguments.clone(), + }); + let output = tools::execute(&call.name, call.arguments.clone(), &tool_ctx).await; + let _ = tx.send(AgentEvent::ToolResult { + id: call.id.clone(), + name: call.name.clone(), + ok: output.ok, + content: output.content.clone(), + }); + conversation.append(Record::Tool { + tool_call_id: call.id, + name: call.name, + ok: output.ok, + content: output.content, + ts: now_ts(), + })?; + } + } + let _ = tx.send(AgentEvent::TurnFinished); + Ok(conversation) +} + +fn build_messages(records: &[Record], system: String, limit: usize) -> Vec { + let mut non_system = Vec::new(); + for r in records { + match r { + Record::User { content, .. } => non_system.push(ModelMessage::User { + content: content.clone(), + }), + Record::Assistant { + content, + tool_calls, + .. + } => non_system.push(ModelMessage::Assistant { + content: content.clone(), + tool_calls: tool_calls.clone(), + }), + Record::Tool { + tool_call_id, + name, + content, + .. + } => non_system.push(ModelMessage::Tool { + tool_call_id: tool_call_id.clone(), + name: name.clone(), + content: content.clone(), + }), + _ => {} + } + } + if non_system.len() > limit { + non_system = non_system.split_off(non_system.len() - limit); + } + let mut messages = vec![ModelMessage::System { content: system }]; + messages.extend(non_system); + repair_tool_message_prefix(messages) +} + +fn repair_tool_message_prefix(mut messages: Vec) -> Vec { + // If context trimming begins with tool results, remove them because OpenAI-compatible APIs + // require tool messages to follow an assistant tool call. + while matches!(messages.get(1), Some(ModelMessage::Tool { .. })) { + messages.remove(1); + } + messages +} + +#[allow(dead_code)] +fn _calls(_calls: Vec) {} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..4dd7b86 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,448 @@ +use crate::agent::{self, AgentEvent, AgentSettings}; +use crate::cli; +use crate::config::Config; +use crate::conversation::{self, Conversation}; +use crate::prompt; +use crate::ui::events::poll_event; +use crate::ui::render::{self, TranscriptBlock, TranscriptKind}; +use crate::ui::terminal; +use anyhow::{Context, Result}; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +pub async fn run() -> Result<()> { + let cli = cli::parse(); + let config = Config::load(&cli)?; + let cwd = resolve_cwd(cli.cwd.clone())?; + + if matches!(cli.resume, Some(None)) { + list_chats(&config, &cwd)?; + return Ok(()); + } + + let (conversation, warning) = if let Some(Some(id)) = cli.resume.clone() { + Conversation::load(&config.conversations_dir(), &id)? + } else { + let global = fs::read_to_string(config.global_path()).ok(); + let base = prompt::build_base_system_prompt(global.as_deref()); + ( + Conversation::create(&config.conversations_dir(), &config.model, &cwd, base)?, + None, + ) + }; + + run_tui(config, cwd, conversation, warning).await +} + +fn resolve_cwd(cwd: Option) -> Result { + let cwd = cwd.unwrap_or(std::env::current_dir()?); + let cwd = cwd + .canonicalize() + .with_context(|| format!("resolving cwd {}", cwd.display()))?; + std::env::set_current_dir(&cwd)?; + Ok(cwd) +} + +fn list_chats(config: &Config, cwd: &std::path::Path) -> Result<()> { + let chats = conversation::list_chats(&config.conversations_dir(), cwd)?; + if chats.is_empty() { + println!("No chats found for {}", cwd.display()); + } else { + for c in chats { + println!( + "{} {} {} {}", + c.id, c.created_at, c.model, c.first_user_preview + ); + } + } + Ok(()) +} + +async fn run_tui( + config: Config, + cwd: PathBuf, + mut conversation: Conversation, + warning: Option, +) -> Result<()> { + let mut terminal = terminal::enter()?; + let mut transcript = Vec::new(); + if let Some(w) = warning { + transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "warning".into(), + content: w, + }); + } + transcript.extend(blocks_from_conversation(&conversation)); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + let mut input = String::new(); + let mut mode = config.default_access_mode; + let mut status = String::new(); + let mut show_full_tools = false; + let mut scroll: u16 = 0; + let mut last_ctrl_c: Option = None; + let mut handle: Option>> = None; + let mut active_assistant: Option = None; + let mut stick_to_bottom = true; + let chat_id = conversation.id.clone(); + + loop { + scroll = clamp_scroll(&terminal, &input, &transcript, show_full_tools, scroll)?; + + terminal.draw(|f| { + render::render( + f, + &render::RenderState { + app_name: "Cassady", + chat_id: &chat_id, + model: &config.model, + mode, + cwd: &cwd, + transcript: &transcript, + input: &input, + status: &status, + busy: handle.is_some(), + show_full_tools, + scroll, + }, + ) + })?; + + while let Ok(event) = rx.try_recv() { + match event { + AgentEvent::AssistantChunk(s) => { + if active_assistant.is_none() && s.trim().is_empty() { + continue; + } + let idx = match active_assistant { + Some(i) => i, + None => { + transcript.push(TranscriptBlock { + kind: TranscriptKind::Assistant, + title: "response".into(), + content: String::new(), + }); + let i = transcript.len() - 1; + active_assistant = Some(i); + i + } + }; + transcript[idx].content.push_str(&s); + if stick_to_bottom { + scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + } + } + AgentEvent::ToolCallStarted { + id, + name, + arguments, + } => { + active_assistant = None; + transcript.push(TranscriptBlock { + kind: TranscriptKind::Tool, + title: format!("call: {name} ({})", short_call_id(&id)), + content: serde_json::to_string_pretty(&arguments) + .unwrap_or_else(|_| arguments.to_string()), + }); + if stick_to_bottom { + scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + } + } + AgentEvent::ToolResult { + id, + name, + ok, + content, + } => { + transcript.push(TranscriptBlock { + kind: if ok { + TranscriptKind::Tool + } else { + TranscriptKind::Error + }, + title: format!( + "result: {name} {} ({})", + if ok { "✓" } else { "✗" }, + short_call_id(&id) + ), + content, + }); + if stick_to_bottom { + scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + } + } + AgentEvent::Status(s) => { + transcript.push(TranscriptBlock { + kind: TranscriptKind::Status, + title: "status".into(), + content: s, + }); + if stick_to_bottom { + scroll = bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + } + } + AgentEvent::TurnFinished => { + active_assistant = None; + status = "turn finished".into(); + } + } + } + + if handle.as_ref().map(|h| h.is_finished()).unwrap_or(false) { + let h = handle.take().unwrap(); + match h.await { + Ok(Ok(updated)) => conversation = updated, + Ok(Err(err)) => transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "agent error".into(), + content: err.to_string(), + }), + Err(err) => transcript.push(TranscriptBlock { + kind: TranscriptKind::Error, + title: "agent task error".into(), + content: err.to_string(), + }), + } + status = "idle".into(); + } + + if let Some(event) = poll_event(Duration::from_millis(40))? { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + let busy = handle.is_some(); + match (key.code, key.modifiers) { + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + let now = Instant::now(); + input.clear(); + if last_ctrl_c + .map(|t| now.duration_since(t) <= Duration::from_millis(1500)) + .unwrap_or(false) + { + terminal::leave(terminal)?; + println!("Resume this chat with: cass --resume {}", chat_id); + return Ok(()); + } + last_ctrl_c = Some(now); + status = "press Ctrl-C again within 1.5s to exit".into(); + } + (KeyCode::BackTab, _) => { + if busy { + status = "mode can be changed when idle".into(); + } else { + mode = mode.toggle(); + status = format!("mode: {mode}"); + } + } + (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => { + show_full_tools = !show_full_tools; + scroll = if stick_to_bottom { + bottom_scroll(&terminal, &input, &transcript, show_full_tools)? + } else { + clamp_scroll( + &terminal, + &input, + &transcript, + show_full_tools, + scroll, + )? + }; + status = if show_full_tools { + "showing full tool output" + } else { + "showing truncated tool output" + } + .into(); + } + (KeyCode::Char('j'), m) if m.contains(KeyModifiers::CONTROL) => { + input.push('\n') + } + (KeyCode::Enter, m) if m.contains(KeyModifiers::CONTROL) => { + input.push('\n') + } + (KeyCode::Enter, _) => { + if busy { + status = "agent is still running".into(); + } else if input.trim().is_empty() { + input.clear(); + } else { + let msg = input.trim_end().to_string(); + input.clear(); + transcript.push(TranscriptBlock { + kind: TranscriptKind::User, + title: "message".into(), + content: msg.clone(), + }); + active_assistant = None; + status = "running".into(); + let settings = AgentSettings { + config: config.clone(), + cwd: cwd.clone(), + mode, + }; + let convo = conversation.clone(); + let tx2 = tx.clone(); + handle = Some(tokio::spawn(async move { + agent::run_turn(convo, msg, settings, tx2).await + })); + stick_to_bottom = true; + scroll = + bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + } + } + (KeyCode::Backspace, _) => { + input.pop(); + } + (KeyCode::PageUp, _) => { + scroll = scroll.saturating_sub(10); + stick_to_bottom = false; + } + (KeyCode::PageDown, _) => { + let max = + bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + scroll = scroll.saturating_add(10).min(max); + stick_to_bottom = scroll >= max; + } + (KeyCode::Char(ch), m) + if !m.contains(KeyModifiers::CONTROL) + && !m.contains(KeyModifiers::ALT) => + { + input.push(ch) + } + _ => {} + } + } + Event::Mouse(mouse) => { + let transcript_area = + render::transcript_area(terminal_area(&terminal)?, &input); + if rect_contains(transcript_area, mouse.column, mouse.row) { + match mouse.kind { + MouseEventKind::ScrollUp => { + scroll = scroll.saturating_sub(5); + stick_to_bottom = false; + } + MouseEventKind::ScrollDown => { + let max = + bottom_scroll(&terminal, &input, &transcript, show_full_tools)?; + scroll = scroll.saturating_add(5).min(max); + stick_to_bottom = scroll >= max; + } + _ => {} + } + } + } + _ => {} + } + } + + if last_ctrl_c + .map(|t| t.elapsed() > Duration::from_millis(1500)) + .unwrap_or(false) + { + last_ctrl_c = None; + } + } +} + +fn short_call_id(id: &str) -> String { + if id.len() <= 12 { + id.to_string() + } else { + format!("{}…{}", &id[..6], &id[id.len() - 4..]) + } +} + +fn terminal_area(terminal: &terminal::CassTerminal) -> Result { + let size = terminal.size()?; + Ok(ratatui::layout::Rect::new(0, 0, size.width, size.height)) +} + +fn bottom_scroll( + terminal: &terminal::CassTerminal, + input: &str, + transcript: &[TranscriptBlock], + show_full_tools: bool, +) -> Result { + let area = render::transcript_area(terminal_area(terminal)?, input); + Ok(render::max_transcript_scroll( + transcript, + show_full_tools, + area, + )) +} + +fn clamp_scroll( + terminal: &terminal::CassTerminal, + input: &str, + transcript: &[TranscriptBlock], + show_full_tools: bool, + scroll: u16, +) -> Result { + Ok(scroll.min(bottom_scroll(terminal, input, transcript, show_full_tools)?)) +} + +fn rect_contains(area: ratatui::layout::Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) +} + +fn blocks_from_conversation(conversation: &Conversation) -> Vec { + let mut blocks = Vec::new(); + for r in &conversation.records { + match r { + conversation::Record::User { content, .. } => blocks.push(TranscriptBlock { + kind: TranscriptKind::User, + title: "message".into(), + content: content.clone(), + }), + conversation::Record::Assistant { + content, + tool_calls, + .. + } => { + if !content.trim().is_empty() { + blocks.push(TranscriptBlock { + kind: TranscriptKind::Assistant, + title: "response".into(), + content: content.clone(), + }); + } + for call in tool_calls { + blocks.push(TranscriptBlock { + kind: TranscriptKind::Tool, + title: format!("call: {} ({})", call.name, short_call_id(&call.id)), + content: serde_json::to_string_pretty(&call.arguments) + .unwrap_or_else(|_| call.arguments.to_string()), + }); + } + } + conversation::Record::Tool { + tool_call_id, + name, + ok, + content, + .. + } => blocks.push(TranscriptBlock { + kind: if *ok { + TranscriptKind::Tool + } else { + TranscriptKind::Error + }, + title: format!( + "result: {name} {} ({})", + if *ok { "✓" } else { "✗" }, + short_call_id(tool_call_id) + ), + content: content.clone(), + }), + _ => {} + } + } + blocks +} diff --git a/src/bin/cassady.rs b/src/bin/cassady.rs new file mode 100644 index 0000000..dc060d7 --- /dev/null +++ b/src/bin/cassady.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + cassady::run().await +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..0baa699 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,38 @@ +use clap::Parser; +use std::path::PathBuf; + +#[derive(Debug, Parser, Clone)] +#[command(name = "cass", version, about = "Cassady/Cass terminal coding agent")] +pub struct Cli { + /// Resume a chat. Without a chat id, list chats for the current cwd. + #[arg(long, num_args = 0..=1, value_name = "CHAT_ID")] + pub resume: Option>, + + /// Override model for this session. + #[arg(long)] + pub model: Option, + + /// Override OpenAI-compatible base URL. + #[arg(long)] + pub base_url: Option, + + /// Override API key environment variable name. + #[arg(long)] + pub api_key_env: Option, + + /// Set launch cwd/root for relative paths. + #[arg(long)] + pub cwd: Option, + + /// Force read-only mode. + #[arg(long, conflicts_with = "full_access")] + pub readonly: bool, + + /// Force full-access mode. + #[arg(long, conflicts_with = "readonly")] + pub full_access: bool, +} + +pub fn parse() -> Cli { + Cli::parse() +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..263032b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,121 @@ +use crate::access::AccessMode; +use crate::cli::Cli; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigFile { + pub provider: Option, + pub model: Option, + pub base_url: Option, + pub api_key_env: Option, + pub default_access_mode: Option, + pub context_message_limit: Option, + pub model_tool_result_limit: Option, + pub ui_tool_result_limit: Option, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub provider: String, + pub model: String, + pub base_url: String, + pub api_key_env: String, + pub default_access_mode: AccessMode, + pub context_message_limit: usize, + pub model_tool_result_limit: usize, + pub ui_tool_result_limit: usize, + pub root: PathBuf, +} + +impl Default for Config { + fn default() -> Self { + Self { + provider: "openai-compatible".to_string(), + model: "accounts/fireworks/models/qwen3p7-plus".to_string(), + base_url: "https://api.fireworks.ai/inference/v1".to_string(), + api_key_env: "FIREWORKS_API_KEY".to_string(), + default_access_mode: AccessMode::ReadOnly, + context_message_limit: 80, + model_tool_result_limit: 24_000, + ui_tool_result_limit: 4_000, + root: cass_root(), + } + } +} + +pub fn cass_root() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cass") +} + +impl Config { + pub fn load(cli: &Cli) -> Result { + let root = cass_root(); + fs::create_dir_all(root.join("conversations")) + .with_context(|| format!("creating {}", root.join("conversations").display()))?; + let mut cfg = Config::default(); + cfg.root = root.clone(); + + let path = root.join("config.json"); + if path.exists() { + let text = + fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let file: ConfigFile = serde_json::from_str(&text) + .with_context(|| format!("parsing {}", path.display()))?; + if let Some(v) = file.provider { + cfg.provider = v; + } + if let Some(v) = file.model { + cfg.model = v; + } + if let Some(v) = file.base_url { + cfg.base_url = v; + } + if let Some(v) = file.api_key_env { + cfg.api_key_env = v; + } + if let Some(v) = file.default_access_mode { + cfg.default_access_mode = v; + } + if let Some(v) = file.context_message_limit { + cfg.context_message_limit = v; + } + if let Some(v) = file.model_tool_result_limit { + cfg.model_tool_result_limit = v; + } + if let Some(v) = file.ui_tool_result_limit { + cfg.ui_tool_result_limit = v; + } + } + + if let Some(v) = &cli.model { + cfg.model = v.clone(); + } + if let Some(v) = &cli.base_url { + cfg.base_url = v.clone(); + } + if let Some(v) = &cli.api_key_env { + cfg.api_key_env = v.clone(); + } + if cli.readonly { + cfg.default_access_mode = AccessMode::ReadOnly; + } + if cli.full_access { + cfg.default_access_mode = AccessMode::FullAccess; + } + + Ok(cfg) + } + + pub fn conversations_dir(&self) -> PathBuf { + self.root.join("conversations") + } + + pub fn global_path(&self) -> PathBuf { + self.root.join("global.md") + } +} diff --git a/src/conversation.rs b/src/conversation.rs new file mode 100644 index 0000000..79dd5f5 --- /dev/null +++ b/src/conversation.rs @@ -0,0 +1,230 @@ +use anyhow::{Context, Result}; +use chrono::{DateTime, Local, Utc}; +use nanoid::nanoid; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File, OpenOptions}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Record { + Meta { + chat_id: String, + created_at: String, + model: String, + cwd: String, + }, + System { + content: String, + }, + User { + content: String, + ts: String, + }, + Assistant { + content: String, + tool_calls: Vec, + ts: String, + }, + Tool { + tool_call_id: String, + name: String, + ok: bool, + content: String, + ts: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StoredToolCall { + pub id: String, + pub name: String, + pub arguments: serde_json::Value, +} + +#[derive(Debug, Clone)] +pub struct Conversation { + pub id: String, + pub path: PathBuf, + pub records: Vec, +} + +#[derive(Debug, Clone)] +pub struct ChatSummary { + pub id: String, + pub created_at: String, + pub model: String, + pub cwd: String, + pub first_user_preview: String, +} + +pub fn new_chat_id() -> String { + let ts: DateTime = Local::now(); + format!("{}-{}", ts.format("%Y-%m-%d-%H%M%S"), nanoid!(4)) +} + +pub fn now_ts() -> String { + Utc::now().to_rfc3339() +} + +impl Conversation { + pub fn create( + conversations_dir: &Path, + model: &str, + cwd: &Path, + base_system: String, + ) -> Result { + fs::create_dir_all(conversations_dir)?; + let id = new_chat_id(); + let path = conversations_dir.join(format!("{id}.jsonl")); + let mut convo = Conversation { + id: id.clone(), + path, + records: Vec::new(), + }; + convo.append(Record::Meta { + chat_id: id, + created_at: now_ts(), + model: model.to_string(), + cwd: cwd.display().to_string(), + })?; + convo.append(Record::System { + content: base_system, + })?; + Ok(convo) + } + + pub fn load(conversations_dir: &Path, id: &str) -> Result<(Self, Option)> { + let path = conversations_dir.join(format!("{id}.jsonl")); + let file = File::open(&path).with_context(|| format!("opening {}", path.display()))?; + let reader = BufReader::new(file); + let mut records = Vec::new(); + let mut warning = None; + for (idx, line) in reader.lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(record) => records.push(record), + Err(err) => { + warning = Some(format!( + "Stopped loading at corrupted JSONL line {}: {}", + idx + 1, + err + )); + break; + } + } + } + Ok(( + Conversation { + id: id.to_string(), + path, + records, + }, + warning, + )) + } + + pub fn append(&mut self, record: Record) -> Result<()> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&self.path) + .with_context(|| format!("opening {} for append", self.path.display()))?; + let line = serde_json::to_string(&record)?; + writeln!(file, "{line}")?; + file.flush()?; + self.records.push(record); + Ok(()) + } + + pub fn base_system_prompt(&self) -> String { + self.records + .iter() + .find_map(|r| match r { + Record::System { content } => Some(content.clone()), + _ => None, + }) + .unwrap_or_default() + } + + pub fn meta_cwd(&self) -> Option { + self.records.iter().find_map(|r| match r { + Record::Meta { cwd, .. } => Some(cwd.clone()), + _ => None, + }) + } +} + +pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result> { + let mut out = Vec::new(); + if !conversations_dir.exists() { + return Ok(out); + } + let cwd_str = cwd.display().to_string(); + for entry in fs::read_dir(conversations_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("jsonl") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if let Ok(summary) = summarize_chat(stem, &path) { + if summary.cwd == cwd_str { + out.push(summary); + } + } + } + out.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(out) +} + +fn summarize_chat(id: &str, path: &Path) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + let mut created_at = String::new(); + let mut model = String::new(); + let mut cwd = String::new(); + let mut first_user_preview = String::new(); + for line in reader.lines().take(100) { + let line = line?; + let record: Record = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => break, + }; + match record { + Record::Meta { + created_at: c, + model: m, + cwd: w, + .. + } => { + created_at = c; + model = m; + cwd = w; + } + Record::User { content, .. } if first_user_preview.is_empty() => { + first_user_preview = content + .lines() + .next() + .unwrap_or("") + .chars() + .take(80) + .collect(); + } + _ => {} + } + } + Ok(ChatSummary { + id: id.to_string(), + created_at, + model, + cwd, + first_user_preview, + }) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8d04ed3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CassError { + #[error("tool `{tool}` is unavailable in {mode} mode")] + ToolUnavailable { tool: String, mode: String }, + + #[error("path escapes read-only root: {path}")] + PathEscapesRoot { path: String }, + + #[error("invalid arguments for {tool}: {message}")] + InvalidToolArgs { tool: String, message: String }, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..96d7033 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +pub mod access; +pub mod agent; +pub mod app; +pub mod cli; +pub mod config; +pub mod conversation; +pub mod error; +pub mod prompt; +pub mod providers; +pub mod tools; +pub mod ui; + +pub async fn run() -> anyhow::Result<()> { + app::run().await +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..dc060d7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() -> anyhow::Result<()> { + cassady::run().await +} diff --git a/src/prompt.rs b/src/prompt.rs new file mode 100644 index 0000000..24bada8 --- /dev/null +++ b/src/prompt.rs @@ -0,0 +1,46 @@ +use crate::access::AccessMode; +use std::path::Path; + +pub fn build_base_system_prompt(global: Option<&str>) -> String { + let mut prompt = String::new(); + prompt.push_str("1. Identity and operating style\n\n"); + prompt.push_str("You are Cassady, also called Cass, a minimal coding agent running in a terminal chat interface. Work carefully, inspect files before changing them, explain concise next steps, and avoid unnecessary ceremony.\n\n"); + + if let Some(global) = global.map(str::trim).filter(|s| !s.is_empty()) { + prompt.push_str("2. User global instructions\n\n"); + prompt.push_str("The following additional instructions were provided by the user. Follow them when they do not conflict with runtime safety constraints.\n\n"); + prompt.push_str(global); + prompt.push_str("\n\n"); + } + + prompt.push_str("3. Tool-use style\n\n"); + prompt.push_str("Use tools when you need current filesystem context. Prefer targeted inspection over guessing. Batch related reads into one read call when possible. Use grep before read when a directory or file may be too large to inspect directly.\n\n"); + prompt.push_str("4. Editing style\n\n"); + prompt.push_str("Use edit for targeted changes. Each edit must identify exact old text that appears uniquely in the file and replacement text. Do not use write to make small modifications to existing files unless a full rewrite is intentionally safer.\n"); + prompt +} + +pub fn build_effective_system_prompt( + base: &str, + mode: AccessMode, + cwd: &Path, + model: &str, + allowed_tools: &[String], +) -> String { + let mut prompt = String::new(); + prompt.push_str(base.trim_end()); + prompt.push_str("\n\n5. Current runtime constraints\n\n"); + prompt.push_str(&format!("Model: {model}.\n")); + prompt.push_str(&format!("Access mode: {}.\n", mode.as_str())); + prompt.push_str(&format!("Launch working directory: {}.\n", cwd.display())); + prompt.push_str(&format!( + "Allowed tools this turn: {}.\n\n", + allowed_tools.join(", ") + )); + match mode { + AccessMode::ReadOnly => prompt.push_str("In read-only mode, you may inspect files with ls, read, and grep only inside the launch working directory. Do not request write or edit. If a task requires modification, explain what needs full-access mode.\n\n"), + AccessMode::FullAccess => prompt.push_str("In full-access mode, you may request ls, read, grep, write, and edit when needed. Cass does not restrict paths to the launch directory, but normal operating-system permissions still apply.\n\n"), + } + prompt.push_str("6. Response behavior\n\nAssistant output is streamed to the user. Keep user-facing text direct and useful. Tool calls and results are visible to the user, so avoid claiming work happened until the relevant tool result confirms it.\n"); + prompt +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..14123cf --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,2 @@ +pub mod openai_compatible; +pub mod types; diff --git a/src/providers/openai_compatible.rs b/src/providers/openai_compatible.rs new file mode 100644 index 0000000..a4f3592 --- /dev/null +++ b/src/providers/openai_compatible.rs @@ -0,0 +1,188 @@ +use super::types::{CompletionResult, ModelMessage}; +use crate::agent::AgentEvent; +use crate::conversation::StoredToolCall; +use crate::tools::ToolSpec; +use anyhow::{bail, Context, Result}; +use futures_util::StreamExt; +use reqwest::Client; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use tokio::sync::mpsc; + +#[derive(Debug, Clone)] +pub struct OpenAiCompatibleProvider { + client: Client, + model: String, + base_url: String, + api_key: String, +} + +#[derive(Debug, Default)] +struct PartialToolCall { + id: Option, + name: Option, + arguments: String, +} + +impl OpenAiCompatibleProvider { + pub fn new(model: String, base_url: String, api_key_env: String) -> Result { + let api_key = std::env::var(&api_key_env) + .with_context(|| format!("missing API key environment variable `{api_key_env}`"))?; + Ok(Self { + client: Client::new(), + model, + base_url: normalize_base_url(&base_url), + api_key, + }) + } + + pub async fn complete( + &self, + messages: Vec, + tools: Vec, + tx: &mpsc::UnboundedSender, + ) -> Result { + let url = chat_url(&self.base_url); + let body = json!({ + "model": self.model, + "messages": messages_to_openai(messages), + "tools": tools_to_openai(tools), + "stream": true + }); + let resp = self + .client + .post(url) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + bail!("provider returned {status}: {text}"); + } + + let mut content = String::new(); + let mut partials: BTreeMap = BTreeMap::new(); + let mut buf = String::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + buf.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(pos) = buf.find("\n\n") { + let frame = buf[..pos].to_string(); + buf = buf[pos + 2..].to_string(); + for line in frame.lines() { + let line = line.trim(); + if !line.starts_with("data:") { + continue; + } + let data = line.trim_start_matches("data:").trim(); + if data == "[DONE]" { + continue; + } + handle_chunk(data, &mut content, &mut partials, tx)?; + } + } + } + + let tool_calls = partials + .into_iter() + .filter_map(|(idx, p)| { + let name = p.name?; + let id = p.id.unwrap_or_else(|| format!("call_{idx}")); + let arguments = serde_json::from_str(&p.arguments) + .unwrap_or_else(|_| json!({"_raw": p.arguments})); + Some(StoredToolCall { + id, + name, + arguments, + }) + }) + .collect(); + Ok(CompletionResult { + content, + tool_calls, + }) + } +} + +fn handle_chunk( + data: &str, + content: &mut String, + partials: &mut BTreeMap, + tx: &mpsc::UnboundedSender, +) -> Result<()> { + let v: Value = serde_json::from_str(data)?; + let Some(choice) = v.get("choices").and_then(|c| c.get(0)) else { + return Ok(()); + }; + let delta = choice + .get("delta") + .or_else(|| choice.get("message")) + .cloned() + .unwrap_or(Value::Null); + if let Some(s) = delta.get("content").and_then(|c| c.as_str()) { + content.push_str(s); + let _ = tx.send(AgentEvent::AssistantChunk(s.to_string())); + } + if let Some(calls) = delta.get("tool_calls").and_then(|c| c.as_array()) { + for call in calls { + let idx = call.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize; + let p = partials.entry(idx).or_default(); + if let Some(id) = call.get("id").and_then(|i| i.as_str()) { + p.id = Some(id.to_string()); + } + if let Some(function) = call.get("function") { + if let Some(name) = function.get("name").and_then(|n| n.as_str()) { + p.name = Some(name.to_string()); + } + if let Some(args) = function.get("arguments").and_then(|a| a.as_str()) { + p.arguments.push_str(args); + } + } + } + } + Ok(()) +} + +fn messages_to_openai(messages: Vec) -> Vec { + messages.into_iter().map(|m| match m { + ModelMessage::System { content } => json!({"role":"system", "content":content}), + ModelMessage::User { content } => json!({"role":"user", "content":content}), + ModelMessage::Assistant { content, tool_calls } => { + if tool_calls.is_empty() { + json!({"role":"assistant", "content":content}) + } else { + let calls: Vec<_> = tool_calls.into_iter().map(|tc| json!({ + "id": tc.id, + "type": "function", + "function": {"name": tc.name, "arguments": tc.arguments.to_string()} + })).collect(); + json!({"role":"assistant", "content": if content.is_empty() { Value::Null } else { Value::String(content) }, "tool_calls": calls}) + } + } + ModelMessage::Tool { tool_call_id, name: _, content } => json!({"role":"tool", "tool_call_id":tool_call_id, "content":content}), + }).collect() +} + +fn tools_to_openai(tools: Vec) -> Vec { + tools.into_iter().map(|t| json!({ + "type": "function", + "function": {"name": t.name, "description": t.description, "parameters": t.parameters} + })).collect() +} + +fn normalize_base_url(url: &str) -> String { + url.trim_end_matches('/') + .trim_end_matches("/chat/completions") + .to_string() +} + +fn chat_url(base: &str) -> String { + if base.ends_with("/chat/completions") { + base.to_string() + } else { + format!("{}/chat/completions", base.trim_end_matches('/')) + } +} diff --git a/src/providers/types.rs b/src/providers/types.rs new file mode 100644 index 0000000..ec882a0 --- /dev/null +++ b/src/providers/types.rs @@ -0,0 +1,29 @@ +use crate::conversation::StoredToolCall; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "role", rename_all = "snake_case")] +pub enum ModelMessage { + System { + content: String, + }, + User { + content: String, + }, + Assistant { + content: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tool_calls: Vec, + }, + Tool { + tool_call_id: String, + name: String, + content: String, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct CompletionResult { + pub content: String, + pub tool_calls: Vec, +} diff --git a/src/tools/edit.rs b/src/tools/edit.rs new file mode 100644 index 0000000..6a96acd --- /dev/null +++ b/src/tools/edit.rs @@ -0,0 +1,84 @@ +use super::{schema, ToolContext, ToolSpec}; +use anyhow::{bail, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::ops::Range; + +#[derive(Debug, Deserialize)] +struct Args { + path: String, + edits: Vec, +} +#[derive(Debug, Deserialize)] +struct EditArg { + old_text: String, + new_text: String, +} + +pub fn spec() -> ToolSpec { + ToolSpec { + name: "edit".into(), + description: "Safely edit a file using exact old_text/new_text replacements. Every old_text must match exactly once in the original file. Requires full-access mode.".into(), + parameters: schema::object(json!({ + "path": {"type":"string"}, + "edits": {"type":"array", "items": { + "type":"object", + "properties": { + "old_text": {"type":"string"}, + "new_text": {"type":"string"} + }, + "required":["old_text", "new_text"], + "additionalProperties": false + }} + }), &["path", "edits"]), + } +} + +pub fn run(args: Value, ctx: &ToolContext) -> Result { + let args: Args = serde_json::from_value(args)?; + if args.edits.is_empty() { + bail!("edit requires at least one replacement"); + } + let path = super::path::resolve_for_write(&args.path, &ctx.cwd, ctx.mode)?; + let original = fs::read_to_string(&path)?; + let mut ranges: Vec<(Range, &str)> = Vec::new(); + for edit in &args.edits { + if edit.old_text.is_empty() { + bail!("old_text must not be empty"); + } + let matches: Vec<_> = original.match_indices(&edit.old_text).collect(); + if matches.is_empty() { + bail!("old_text not found: {:?}", preview(&edit.old_text)); + } + if matches.len() > 1 { + bail!("old_text is not unique: {:?}", preview(&edit.old_text)); + } + let start = matches[0].0; + ranges.push((start..start + edit.old_text.len(), edit.new_text.as_str())); + } + ranges.sort_by_key(|(r, _)| r.start); + for pair in ranges.windows(2) { + if pair[0].0.end > pair[1].0.start { + bail!("edits overlap; no changes written"); + } + } + let mut out = String::new(); + let mut cursor = 0; + for (range, replacement) in &ranges { + out.push_str(&original[cursor..range.start]); + out.push_str(replacement); + cursor = range.end; + } + out.push_str(&original[cursor..]); + super::write::atomic_write(&path, out.as_bytes())?; + Ok(format!( + "applied {} edit(s) to {}", + args.edits.len(), + path.display() + )) +} + +fn preview(s: &str) -> String { + s.chars().take(80).collect() +} diff --git a/src/tools/grep.rs b/src/tools/grep.rs new file mode 100644 index 0000000..067bd86 --- /dev/null +++ b/src/tools/grep.rs @@ -0,0 +1,151 @@ +use super::{schema, ToolContext, ToolSpec}; +use anyhow::{bail, Result}; +use ignore::WalkBuilder; +use regex::RegexBuilder; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Deserialize)] +struct Args { + query: String, + #[serde(default = "default_paths")] + paths: Vec, + #[serde(default)] + regex: bool, + #[serde(default)] + case_sensitive: bool, + #[serde(default)] + context_lines: usize, + #[serde(default = "default_max")] + max_matches: usize, +} +fn default_paths() -> Vec { + vec![".".into()] +} +fn default_max() -> usize { + 100 +} + +pub fn spec() -> ToolSpec { + ToolSpec { + name: "grep".into(), + description: "Search files or directories for literal text or regex matches. Use before read for large inputs.".into(), + parameters: schema::object(json!({ + "query": {"type":"string"}, + "paths": {"type":"array", "items":{"type":"string"}, "default":["."]}, + "regex": {"type":"boolean", "default":false}, + "case_sensitive": {"type":"boolean", "default":false}, + "context_lines": {"type":"integer", "default":0, "minimum":0, "maximum":5}, + "max_matches": {"type":"integer", "default":100, "minimum":1, "maximum":1000} + }), &["query"]), + } +} + +pub fn run(args: Value, ctx: &ToolContext) -> Result { + let args: Args = serde_json::from_value(args)?; + if args.query.is_empty() { + bail!("grep query must not be empty"); + } + let max_matches = args.max_matches.clamp(1, 1000); + let context = args.context_lines.min(5); + let re = if args.regex { + RegexBuilder::new(&args.query) + .case_insensitive(!args.case_sensitive) + .build()? + } else { + RegexBuilder::new(®ex::escape(&args.query)) + .case_insensitive(!args.case_sensitive) + .build()? + }; + + let mut files = Vec::new(); + for p in &args.paths { + let path = super::path::resolve_existing(p, &ctx.cwd, &ctx.read_only_root, ctx.mode)?; + collect_files(&path, &mut files)?; + } + + let mut out = String::new(); + let mut count = 0usize; + 'files: for file in files { + let bytes = match fs::read(&file) { + Ok(b) => b, + Err(_) => continue, + }; + if super::path::is_probably_binary(&bytes) { + continue; + } + let text = String::from_utf8_lossy(&bytes); + let lines: Vec<&str> = text.lines().collect(); + for (idx, line) in lines.iter().enumerate() { + if re.is_match(line) { + count += 1; + if context == 0 { + out.push_str(&format!("{}:{}: {}\n", file.display(), idx + 1, line)); + } else { + out.push_str(&format!("--- {}:{} ---\n", file.display(), idx + 1)); + let start = idx.saturating_sub(context); + let end = (idx + context + 1).min(lines.len()); + for (j, l) in lines.iter().enumerate().take(end).skip(start) { + let mark = if j == idx { ">" } else { " " }; + out.push_str(&format!("{mark}{:>6} | {}\n", j + 1, l)); + } + } + if count >= max_matches { + out.push_str(&format!( + "… stopped after {count} matches. Narrow the query or raise max_matches.\n" + )); + break 'files; + } + } + } + } + if count == 0 { + out.push_str("no matches\n"); + } + Ok(out) +} + +fn collect_files(path: &Path, files: &mut Vec) -> Result<()> { + if path.is_file() { + files.push(path.to_path_buf()); + return Ok(()); + } + if !path.is_dir() { + bail!("not a file or directory: {}", path.display()); + } + let mut builder = WalkBuilder::new(path); + builder + .hidden(false) + .git_ignore(true) + .git_exclude(true) + .git_global(true); + for result in builder.build() { + let entry = match result { + Ok(e) => e, + Err(_) => continue, + }; + let p = entry.path(); + if should_skip(p) { + if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + // ignore crate cannot prune here without filter_entry; cheap enough for v1. + } + continue; + } + if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + files.push(p.to_path_buf()); + } + } + Ok(()) +} + +fn should_skip(path: &Path) -> bool { + path.components().any(|c| { + let s = c.as_os_str().to_string_lossy(); + matches!( + s.as_ref(), + ".git" | "target" | "node_modules" | "vendor" | ".cache" + ) + }) +} diff --git a/src/tools/ls.rs b/src/tools/ls.rs new file mode 100644 index 0000000..e6c16b4 --- /dev/null +++ b/src/tools/ls.rs @@ -0,0 +1,80 @@ +use super::{schema, ToolContext, ToolSpec}; +use anyhow::{bail, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; + +#[derive(Debug, Deserialize)] +struct Args { + #[serde(default = "default_path")] + path: String, +} +fn default_path() -> String { + ".".into() +} + +pub fn spec() -> ToolSpec { + ToolSpec { + name: "ls".into(), + description: "List a directory. In read-only mode, paths must stay inside the launch cwd." + .into(), + parameters: schema::object( + json!({ + "path": {"type":"string", "description":"Directory path to list", "default":"."} + }), + &["path"], + ), + } +} + +pub fn run(args: Value, ctx: &ToolContext) -> Result { + let args: Args = serde_json::from_value(args)?; + let path = super::path::resolve_existing(&args.path, &ctx.cwd, &ctx.read_only_root, ctx.mode)?; + if !path.is_dir() { + bail!("not a directory: {}", path.display()); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&path)? { + let entry = entry?; + let meta = entry.metadata()?; + let mut name = entry.file_name().to_string_lossy().to_string(); + let kind = if meta.is_dir() { + name.push('/'); + "dir".to_string() + } else { + format_size(meta.len()) + }; + entries.push((name, kind)); + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let name_width = entries + .iter() + .map(|(name, _)| name.chars().count()) + .max() + .unwrap_or(4) + .min(48); + let mut out = format!("{}\n", path.display()); + for (name, kind) in entries { + out.push_str(&format!(" {name: String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", bytes, UNITS[unit]) + } else if size >= 10.0 { + format!("{size:.0} {}", UNITS[unit]) + } else { + format!("{size:.1} {}", UNITS[unit]) + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs new file mode 100644 index 0000000..0b4d426 --- /dev/null +++ b/src/tools/mod.rs @@ -0,0 +1,90 @@ +pub mod edit; +pub mod grep; +pub mod ls; +pub mod path; +pub mod read; +pub mod schema; +pub mod write; + +use crate::access::AccessMode; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolSpec { + pub name: String, + pub description: String, + pub parameters: Value, +} + +#[derive(Debug, Clone)] +pub struct ToolContext { + pub mode: AccessMode, + pub cwd: PathBuf, + pub read_only_root: PathBuf, + pub model_result_limit: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolOutput { + pub ok: bool, + pub content: String, +} + +pub fn available_tool_names(mode: AccessMode) -> Vec { + match mode { + AccessMode::ReadOnly => vec!["ls".into(), "read".into(), "grep".into()], + AccessMode::FullAccess => vec![ + "ls".into(), + "read".into(), + "grep".into(), + "write".into(), + "edit".into(), + ], + } +} + +pub fn specs(mode: AccessMode) -> Vec { + let mut specs = vec![ls::spec(), read::spec(), grep::spec()]; + if mode.can_write() { + specs.push(write::spec()); + specs.push(edit::spec()); + } + specs +} + +pub async fn execute(name: &str, args: Value, ctx: &ToolContext) -> ToolOutput { + let result: Result = match name { + "ls" => ls::run(args, ctx), + "read" => read::run(args, ctx), + "grep" => grep::run(args, ctx), + "write" if ctx.mode.can_write() => write::run(args, ctx), + "edit" if ctx.mode.can_write() => edit::run(args, ctx), + "write" | "edit" => Err(anyhow::anyhow!( + "tool `{name}` is unavailable in {} mode", + ctx.mode + )), + _ => Err(anyhow::anyhow!("unknown tool `{name}`")), + }; + match result { + Ok(content) => ToolOutput { + ok: true, + content: truncate_model(content, ctx.model_result_limit), + }, + Err(err) => ToolOutput { + ok: false, + content: err.to_string(), + }, + } +} + +fn truncate_model(mut s: String, limit: usize) -> String { + if s.len() <= limit { + return s; + } + s.truncate(limit); + s.push_str("\n… truncated by Cass; use grep or narrower line ranges for more."); + s +} diff --git a/src/tools/path.rs b/src/tools/path.rs new file mode 100644 index 0000000..02bbf3d --- /dev/null +++ b/src/tools/path.rs @@ -0,0 +1,57 @@ +use crate::access::AccessMode; +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +pub fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); + } + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(path) +} + +pub fn resolve_existing(input: &str, cwd: &Path, root: &Path, mode: AccessMode) -> Result { + let p = expand_tilde(input); + let abs = if p.is_absolute() { p } else { cwd.join(p) }; + let canon = fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display()))?; + if matches!(mode, AccessMode::ReadOnly) { + let root_canon = + fs::canonicalize(root).with_context(|| format!("resolving root {}", root.display()))?; + if !canon.starts_with(&root_canon) { + bail!("path escapes read-only root: {}", abs.display()); + } + } + Ok(canon) +} + +pub fn resolve_for_write(input: &str, cwd: &Path, mode: AccessMode) -> Result { + if !mode.can_write() { + bail!("write access requires full-access mode"); + } + let p = expand_tilde(input); + let abs = if p.is_absolute() { p } else { cwd.join(p) }; + Ok(normalize_lexical(&abs)) +} + +fn normalize_lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +pub fn is_probably_binary(bytes: &[u8]) -> bool { + bytes.iter().take(8192).any(|b| *b == 0) +} diff --git a/src/tools/read.rs b/src/tools/read.rs new file mode 100644 index 0000000..d952ab1 --- /dev/null +++ b/src/tools/read.rs @@ -0,0 +1,101 @@ +use super::{schema, ToolContext, ToolSpec}; +use anyhow::{bail, Result}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; + +#[derive(Debug, Deserialize)] +struct Args { + files: Vec, +} + +#[derive(Debug, Deserialize)] +struct FileArg { + path: String, + lines: Option, +} + +pub fn spec() -> ToolSpec { + ToolSpec { + name: "read".into(), + description: "Read one or more text files, optionally with 1-indexed line ranges like 35-60, 35-, or -60.".into(), + parameters: schema::object(json!({ + "files": { + "type":"array", + "items": { + "type":"object", + "properties": { + "path": {"type":"string"}, + "lines": {"type":"string", "description":"Optional line range, e.g. 35-60, 35-, -60"} + }, + "required":["path"], + "additionalProperties": false + } + } + }), &["files"]), + } +} + +pub fn run(args: Value, ctx: &ToolContext) -> Result { + let args: Args = serde_json::from_value(args)?; + if args.files.is_empty() { + bail!("read requires at least one file"); + } + let mut out = String::new(); + for file in args.files { + let path = + super::path::resolve_existing(&file.path, &ctx.cwd, &ctx.read_only_root, ctx.mode)?; + if !path.is_file() { + bail!("not a file: {}", path.display()); + } + let bytes = fs::read(&path)?; + if super::path::is_probably_binary(&bytes) { + bail!("binary files are unsupported: {}", path.display()); + } + let text = String::from_utf8_lossy(&bytes); + let lines: Vec<&str> = text.lines().collect(); + let (start, end) = parse_range(file.lines.as_deref(), lines.len())?; + out.push_str(&format!( + "--- {} lines {}-{} ---\n", + path.display(), + start, + end + )); + for (idx, line) in lines + .iter() + .enumerate() + .take(end) + .skip(start.saturating_sub(1)) + { + out.push_str(&format!("{:>6} | {}\n", idx + 1, line)); + } + } + Ok(out) +} + +fn parse_range(range: Option<&str>, len: usize) -> Result<(usize, usize)> { + if len == 0 { + return Ok((1, 0)); + } + let Some(range) = range.filter(|s| !s.trim().is_empty()) else { + return Ok((1, len)); + }; + let parts: Vec<_> = range.split('-').collect(); + if parts.len() != 2 { + bail!("invalid line range `{range}`"); + } + let start = if parts[0].is_empty() { + 1 + } else { + parts[0].parse::()? + }; + let end = if parts[1].is_empty() { + len + } else { + parts[1].parse::()? + }; + if start == 0 || end < start { + bail!("invalid line range `{range}`"); + } + Ok((start.min(len), end.min(len))) +} diff --git a/src/tools/schema.rs b/src/tools/schema.rs new file mode 100644 index 0000000..9db5d46 --- /dev/null +++ b/src/tools/schema.rs @@ -0,0 +1,10 @@ +use serde_json::{json, Value}; + +pub fn object(properties: Value, required: &[&str]) -> Value { + json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false + }) +} diff --git a/src/tools/write.rs b/src/tools/write.rs new file mode 100644 index 0000000..0370b64 --- /dev/null +++ b/src/tools/write.rs @@ -0,0 +1,54 @@ +use super::{schema, ToolContext, ToolSpec}; +use anyhow::{bail, Result}; +use nanoid::nanoid; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::fs; +use std::io::Write as _; + +#[derive(Debug, Deserialize)] +struct Args { + path: String, + content: String, +} + +pub fn spec() -> ToolSpec { + ToolSpec { + name: "write".into(), + description: "Create or overwrite a text file. Requires full-access mode. Uses atomic temp-file-and-rename where practical.".into(), + parameters: schema::object(json!({ + "path": {"type":"string"}, + "content": {"type":"string"} + }), &["path", "content"]), + } +} + +pub fn run(args: Value, ctx: &ToolContext) -> Result { + let args: Args = serde_json::from_value(args)?; + let path = super::path::resolve_for_write(&args.path, &ctx.cwd, ctx.mode)?; + atomic_write(&path, args.content.as_bytes())?; + Ok(format!( + "wrote {} bytes to {}", + args.content.len(), + path.display() + )) +} + +pub fn atomic_write(path: &std::path::Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("path has no parent: {}", path.display()))?; + fs::create_dir_all(parent)?; + let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("file"); + let tmp = parent.join(format!(".{file_name}.cass-tmp-{}", nanoid!(8))); + { + let mut f = fs::File::create(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + if let Err(err) = fs::rename(&tmp, path) { + let _ = fs::remove_file(&tmp); + bail!(err); + } + Ok(()) +} diff --git a/src/ui/events.rs b/src/ui/events.rs new file mode 100644 index 0000000..0f8a480 --- /dev/null +++ b/src/ui/events.rs @@ -0,0 +1,10 @@ +use crossterm::event::{self, Event}; +use std::time::Duration; + +pub fn poll_event(timeout: Duration) -> std::io::Result> { + if event::poll(timeout)? { + Ok(Some(event::read()?)) + } else { + Ok(None) + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..6ef2ad3 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,4 @@ +pub mod events; +pub mod render; +pub mod terminal; +pub mod theme; diff --git a/src/ui/render.rs b/src/ui/render.rs new file mode 100644 index 0000000..239f0a3 --- /dev/null +++ b/src/ui/render.rs @@ -0,0 +1,274 @@ +use crate::access::AccessMode; +use crate::ui::theme; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use std::path::Path; + +#[derive(Debug, Clone)] +pub enum TranscriptKind { + User, + Assistant, + Tool, + Status, + Error, +} + +#[derive(Debug, Clone)] +pub struct TranscriptBlock { + pub kind: TranscriptKind, + pub title: String, + pub content: String, +} + +#[derive(Debug)] +pub struct RenderState<'a> { + pub app_name: &'a str, + pub chat_id: &'a str, + pub model: &'a str, + pub mode: AccessMode, + pub cwd: &'a Path, + pub transcript: &'a [TranscriptBlock], + pub input: &'a str, + pub status: &'a str, + pub busy: bool, + pub show_full_tools: bool, + pub scroll: u16, +} + +pub fn render(f: &mut Frame<'_>, state: &RenderState<'_>) { + let chunks = main_layout(f.area(), state.input); + + let header = header_text(state, chunks[0].width as usize); + f.render_widget(Paragraph::new(header).style(theme::header()), chunks[0]); + + let lines = transcript_lines_from(state.transcript, state.show_full_tools); + let effective_scroll = state.scroll.min(max_transcript_scroll( + state.transcript, + state.show_full_tools, + chunks[1], + )); + let transcript = Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(" Transcript ")) + .wrap(Wrap { trim: false }) + .scroll((effective_scroll, 0)); + f.render_widget(transcript, chunks[1]); + + let input_title = if state.busy { + " Input · waiting " + } else { + " Input " + }; + let input = Paragraph::new(state.input.to_string()) + .block(Block::default().borders(Borders::ALL).title(input_title)) + .wrap(Wrap { trim: false }); + f.render_widget(input, chunks[2]); + + let footer = truncate_end( + &format!( + " Shift-Tab mode | Enter send | Ctrl-J newline | Ctrl-O tools | wheel/PgUp/PgDn scroll | Ctrl-C twice exit {}", + state.status + ), + chunks[3].width as usize, + ); + f.render_widget(Paragraph::new(footer), chunks[3]); +} + +pub fn transcript_area(area: Rect, input: &str) -> Rect { + main_layout(area, input)[1] +} + +pub fn max_transcript_scroll( + transcript: &[TranscriptBlock], + show_full_tools: bool, + area: Rect, +) -> u16 { + let row_count = transcript_lines_from(transcript, show_full_tools).len(); + let viewport_rows = area.height.saturating_sub(2) as usize; + row_count + .saturating_sub(viewport_rows) + .min(u16::MAX as usize) as u16 +} + +fn main_layout(area: Rect, input: &str) -> std::rc::Rc<[Rect]> { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(5), + Constraint::Length(input_height(input)), + Constraint::Length(1), + ]) + .split(area) +} + +fn transcript_lines_from( + transcript: &[TranscriptBlock], + show_full_tools: bool, +) -> Vec> { + let mut lines = Vec::new(); + for block in transcript { + if matches!(block.kind, TranscriptKind::Assistant) && block.content.trim().is_empty() { + continue; + } + + let style = style_for(&block.kind); + lines.push(Line::from(vec![ + Span::styled(symbol_for(&block.kind), style), + Span::raw(" "), + Span::styled(display_title(block), style), + ])); + + let content = display_content(block, show_full_tools); + if !content.trim().is_empty() { + for line in content.lines() { + lines.push(Line::raw(format!(" {}", sanitize_line(line)))); + } + } + lines.push(Line::raw("")); + } + if lines.is_empty() { + lines.push(Line::styled( + "Welcome to Cassady. Ask a question or request a code change.", + Style::default().fg(Color::DarkGray), + )); + } + lines +} + +fn style_for(kind: &TranscriptKind) -> Style { + match kind { + TranscriptKind::User => theme::user(), + TranscriptKind::Assistant => theme::assistant(), + TranscriptKind::Tool => theme::tool(), + TranscriptKind::Status => Style::default().fg(Color::Blue), + TranscriptKind::Error => theme::error(), + } +} + +fn symbol_for(kind: &TranscriptKind) -> &'static str { + match kind { + TranscriptKind::User => "You", + TranscriptKind::Assistant => "Cass", + TranscriptKind::Tool => "Tool", + TranscriptKind::Status => "Info", + TranscriptKind::Error => "Error", + } +} + +fn display_title(block: &TranscriptBlock) -> String { + match block.kind { + TranscriptKind::User => "message".into(), + TranscriptKind::Assistant => "response".into(), + TranscriptKind::Status => block.title.clone(), + TranscriptKind::Error => block.title.clone(), + TranscriptKind::Tool => block.title.clone(), + } +} + +fn display_content(block: &TranscriptBlock, show_full_tools: bool) -> String { + if matches!(block.kind, TranscriptKind::Tool) && !show_full_tools && block.content.len() > 1200 + { + let mut s = block.content.chars().take(1200).collect::(); + s.push_str("\n… truncated, press Ctrl-O to expand"); + s + } else { + block.content.clone() + } +} + +fn header_text(state: &RenderState<'_>, width: usize) -> String { + let busy = if state.busy { "busy" } else { "idle" }; + let mode = match state.mode { + AccessMode::ReadOnly => "read-only", + AccessMode::FullAccess => "full-access", + }; + let model = short_model(state.model); + let cwd = short_path(state.cwd); + let chat = short_chat_id(state.chat_id); + truncate_end( + &format!( + " {} {} · {} model={} cwd={} chat={}", + state.app_name, mode, busy, model, cwd, chat + ), + width, + ) +} + +fn short_model(model: &str) -> String { + model.rsplit('/').next().unwrap_or(model).to_string() +} + +fn short_chat_id(id: &str) -> String { + if id.len() <= 18 { + id.to_string() + } else { + format!("{}…{}", &id[..10], &id[id.len() - 4..]) + } +} + +fn short_path(path: &Path) -> String { + let display = if let Some(home) = dirs::home_dir() { + if let Ok(rest) = path.strip_prefix(&home) { + if rest.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~/{}", rest.display()) + } + } else { + path.display().to_string() + } + } else { + path.display().to_string() + }; + truncate_middle(&display, 40) +} + +fn sanitize_line(line: &str) -> String { + line.replace('\t', " ") + .chars() + .filter(|c| !c.is_control()) + .collect() +} + +fn truncate_end(s: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + if s.chars().count() <= max { + return s.to_string(); + } + if max <= 1 { + return "…".into(); + } + let mut out = s.chars().take(max - 1).collect::(); + out.push('…'); + out +} + +fn truncate_middle(s: &str, max: usize) -> String { + let len = s.chars().count(); + if len <= max { + return s.to_string(); + } + if max <= 1 { + return "…".into(); + } + let head = (max - 1) / 2; + let tail = max - 1 - head; + let start = s.chars().take(head).collect::(); + let end = s + .chars() + .rev() + .take(tail) + .collect::>() + .into_iter() + .rev() + .collect::(); + format!("{start}…{end}") +} + +fn input_height(input: &str) -> u16 { + let lines = input.lines().count().max(1) as u16; + (lines + 2).clamp(3, 8) +} diff --git a/src/ui/terminal.rs b/src/ui/terminal.rs new file mode 100644 index 0000000..5ca9ddb --- /dev/null +++ b/src/ui/terminal.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; + +pub type CassTerminal = Terminal>; + +pub fn enter() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + Ok(Terminal::new(backend)?) +} + +pub fn leave(mut terminal: CassTerminal) -> Result<()> { + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs new file mode 100644 index 0000000..a438b68 --- /dev/null +++ b/src/ui/theme.rs @@ -0,0 +1,21 @@ +use ratatui::style::{Color, Modifier, Style}; + +pub fn header() -> Style { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) +} +pub fn user() -> Style { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) +} +pub fn assistant() -> Style { + Style::default().fg(Color::White) +} +pub fn tool() -> Style { + Style::default().fg(Color::Yellow) +} +pub fn error() -> Style { + Style::default().fg(Color::Red) +} diff --git a/tests/conversation_tests.rs b/tests/conversation_tests.rs new file mode 100644 index 0000000..2b0a9e0 --- /dev/null +++ b/tests/conversation_tests.rs @@ -0,0 +1,25 @@ +use cassady::conversation::{list_chats, Conversation, Record}; +use tempfile::tempdir; + +#[test] +fn conversation_appends_loads_and_lists_by_cwd() { + let root = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let mut convo = + Conversation::create(root.path(), "model", cwd.path(), "base prompt".into()).unwrap(); + convo + .append(Record::User { + content: "hello world".into(), + ts: "now".into(), + }) + .unwrap(); + + let (loaded, warning) = Conversation::load(root.path(), &convo.id).unwrap(); + assert!(warning.is_none()); + assert_eq!(loaded.base_system_prompt(), "base prompt"); + + let chats = list_chats(root.path(), cwd.path()).unwrap(); + assert_eq!(chats.len(), 1); + assert_eq!(chats[0].id, convo.id); + assert_eq!(chats[0].first_user_preview, "hello world"); +} diff --git a/tests/tool_tests.rs b/tests/tool_tests.rs new file mode 100644 index 0000000..a65ecfc --- /dev/null +++ b/tests/tool_tests.rs @@ -0,0 +1,94 @@ +use cassady::access::AccessMode; +use cassady::tools::{self, ToolContext}; +use serde_json::json; +use tempfile::tempdir; + +fn ctx(root: &std::path::Path, mode: AccessMode) -> ToolContext { + ToolContext { + mode, + cwd: root.to_path_buf(), + read_only_root: root.to_path_buf(), + model_result_limit: 100_000, + } +} + +#[tokio::test] +async fn read_only_rejects_write_and_path_escape() { + let dir = tempdir().unwrap(); + let outside = tempdir().unwrap(); + std::fs::write(outside.path().join("x.txt"), "secret").unwrap(); + + let out = tools::execute( + "write", + json!({"path":"x.txt", "content":"hi"}), + &ctx(dir.path(), AccessMode::ReadOnly), + ) + .await; + assert!(!out.ok); + + let out = tools::execute( + "read", + json!({"files":[{"path": outside.path().join("x.txt").display().to_string()}]}), + &ctx(dir.path(), AccessMode::ReadOnly), + ) + .await; + assert!(!out.ok); + assert!(out.content.contains("escapes read-only root")); +} + +#[tokio::test] +async fn read_and_grep_work() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "one\ntwo needle\nthree\n").unwrap(); + let context = ctx(dir.path(), AccessMode::ReadOnly); + + let read = tools::execute( + "read", + json!({"files":[{"path":"a.txt", "lines":"2-3"}]}), + &context, + ) + .await; + assert!(read.ok); + assert!(read.content.contains("two needle")); + assert!(!read.content.contains("one")); + + let grep = tools::execute( + "grep", + json!({"query":"needle", "paths":["."], "case_sensitive": true}), + &context, + ) + .await; + assert!(grep.ok); + assert!(grep.content.contains("a.txt:2")); +} + +#[tokio::test] +async fn edit_is_exact_and_atomic_on_validation_failure() { + let dir = tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap(); + let context = ctx(dir.path(), AccessMode::FullAccess); + + let ok = tools::execute( + "edit", + json!({"path":"a.txt", "edits":[{"old_text":"beta", "new_text":"BETA"}]}), + &context, + ) + .await; + assert!(ok.ok); + assert_eq!( + std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), + "alpha\nBETA\ngamma\n" + ); + + let bad = tools::execute( + "edit", + json!({"path":"a.txt", "edits":[{"old_text":"missing", "new_text":"NOPE"}]}), + &context, + ) + .await; + assert!(!bad.ok); + assert_eq!( + std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), + "alpha\nBETA\ngamma\n" + ); +}