diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..f386b4a --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + # TODO: re-enable once runners are fixed + push: + branches: [does_not_exist] # [ main ] + # pull_request: + +jobs: + ci: + runs-on: docker + container: + image: rust:latest + steps: + - uses: actions/checkout@v4 + + - name: Install rustfmt and clippy + run: rustup component add rustfmt clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Build + run: cargo build + + - name: Format + run: cargo fmt --check + + - name: Clippy + run: cargo clippy -- -D warnings diff --git a/CLAUDE.md b/CLAUDE.md index adb63e4..24b7145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,28 +16,46 @@ Rust TUI coding agent. Ratatui + Crossterm + Tokio. See DESIGN.md for architectu Six modules with strict boundaries: -- `src/app/` — Wiring, lifecycle, tokio runtime setup -- `src/tui/` — Ratatui rendering, input handling, vim modes. Communicates with core ONLY via channels (`UserAction` → core, `UIEvent` ← core). Never touches conversation state directly. -- `src/core/` — Conversation tree, orchestrator loop, sub-agent lifecycle -- `src/provider/` — `ModelProvider` trait + Claude implementation. Leaf module, no internal dependencies. -- `src/tools/` — `Tool` trait, registry, built-in tools. Depends only on `sandbox`. -- `src/sandbox/` — Landlock policy, path validation, command execution. Leaf module. -- `src/session/` — JSONL logging, session read/write. Leaf module. +- `src/app/` -- Wiring, lifecycle, tokio runtime setup +- `src/tui/` -- Ratatui rendering, input handling, vim modes. Communicates with core ONLY via channels (`UserAction` -> core, `UIEvent` <- core). Never touches conversation state directly. +- `src/core/` -- Conversation tree, orchestrator loop, sub-agent lifecycle +- `src/provider/` -- `ModelProvider` trait + Claude implementation. Leaf module, no internal dependencies. +- `src/tools/` -- `Tool` trait, registry, built-in tools. Depends only on `sandbox`. +- `src/sandbox/` -- Landlock policy, path validation, command execution. Leaf module. +- `src/session/` -- JSONL logging, session read/write. Leaf module. -The channel boundary between `tui` and `core` is critical — never bypass it. The TUI is a frontend; core is the engine. This separation enables headless mode for benchmarking. +The channel boundary between `tui` and `core` is critical -- never bypass it. The TUI is a frontend; core is the engine. This separation enables headless mode for benchmarking. ## Code Style - Use `thiserror` for error types, not `anyhow` in library code (`anyhow` only in `main.rs`/`app`) - Prefer `impl Trait` return types over boxing when possible - All public types need doc comments -- No `unwrap()` in non-test code — use `?` or explicit error handling +- No `unwrap()` in non-test code -- use `?` or explicit error handling - Async functions should be cancel-safe where possible - Use `tracing` for structured logging, not `println!` or `log` +## Documentation + +Prefer a literate style: doc comments should explain *why* and *how*, not just restate the signature. + +Use only characters available on a standard US QWERTY keyboard in all doc comments and inline comments. Specifically: +- Use `->` and `<-` instead of Unicode arrow glyphs +- Use `--` instead of em dashes or en dashes +- Use `+`, `-`, `|` for ASCII box diagrams instead of Unicode box-drawing characters +- Use `...` instead of the ellipsis character +- Spell out "Section N.N" instead of the section-sign glyph + +When a function or type implements an external protocol or spec: +- Document the relevant portion of the protocol inline (packet shapes, event sequences, state machines) +- Link to the authoritative external source -- API reference, RFC, WHATWG spec, etc. +- Include a mapping table or lifecycle diagram when there are multiple cases to distinguish + +For example, `run_stream` in `src/provider/claude.rs` documents the full SSE event sequence in a text diagram and links to both the Anthropic streaming reference and the WHATWG SSE spec. Aim for that level of context in any code that speaks a wire format or external API. + ## Conversation Data Model -Events use parent IDs forming a tree (not a flat list). This enables future branching. Every event has: id, parent_id, timestamp, event_type, token_usage. A "turn" is all events between two user messages — this is the unit for token tracking. +Events use parent IDs forming a tree (not a flat list). This enables future branching. Every event has: id, parent_id, timestamp, event_type, token_usage. A "turn" is all events between two user messages -- this is the unit for token tracking. ## Testing @@ -50,8 +68,8 @@ Events use parent IDs forming a tree (not a flat list). This enables future bran ## Key Constraints -- All file I/O and process spawning in tools MUST go through `Sandbox` — never use `std::fs` or `std::process::Command` directly in tool implementations -- The `ModelProvider` trait must remain provider-agnostic — no Claude-specific types in the trait interface +- All file I/O and process spawning in tools MUST go through `Sandbox` -- never use `std::fs` or `std::process::Command` directly in tool implementations +- The `ModelProvider` trait must remain provider-agnostic -- no Claude-specific types in the trait interface - Session JSONL is append-only. Never rewrite history. Branching works by writing new events with different parent IDs. - Token usage must be tracked per-event and aggregatable per-turn diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d032920 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3339 @@ +# 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 = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[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 = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.0", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "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 = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "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", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +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 = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4eacb0641a310445a4c513f2a5e23e19952e269c6a38887254d5f837a305506" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "line-clipping" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.11.0", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[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" + +[[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.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "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 = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "skate" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "futures", + "ratatui", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.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.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "atomic", + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d7d0fce354c88b7982aec4400b3e7fcf723c32737cef571bd165f7613557ee" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee85afca410ac4abba5b584b12e77ea225db6ee5471d0aebaae0861166f9378a" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55839b71ba921e4f75b674cb16f843f4b1f3b26ddfcb3454de1cf65cc021ec0f" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf2e969c2d60ff52e7e98b7392ff1588bffdd1ccd4769eba27222fd3d621571" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0861f0dcdf46ea819407495634953cdcc8a8c7215ab799a7a7ce366be71c7b30" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10053fbf9a374174094915bbce141e87a6bf32ecd9a002980db4b638405e8962" +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-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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.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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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..00c37e0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "skate" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +ratatui = "0.30" +crossterm = { version = "0.29", features = ["event-stream"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +reqwest = { version = "0.13", features = ["stream", "json"] } +futures = "0.3" diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..cf55134 --- /dev/null +++ b/TODO.md @@ -0,0 +1,4 @@ +# Cleanups + +- Move keyboard/event reads in the TUI to a separate thread or async/io loop +- Keep UI and orchestrator in sync (i.e. messages display out of order if you queue up many.) diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..69660e4 --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,85 @@ +//! Application wiring: tracing initialisation, channel setup, and task +//! orchestration. +//! +//! This module is the only place that knows about all subsystems simultaneously. +//! It creates the two channels that connect the TUI to the core orchestrator, +//! spawns the orchestrator as a background tokio task, and then hands control to +//! the TUI event loop on the calling task. +//! +//! # Shutdown sequence +//! +//! ```text +//! User presses Ctrl-C / Ctrl-D +//! -> tui::run sends UserAction::Quit, breaks loop, drops action_tx +//! -> restore_terminal(), tui::run returns Ok(()) +//! -> app::run returns Ok(()) +//! -> tokio runtime drops the spawned orchestrator task +//! (action_rx channel closed -> orchestrator recv() returns None -> run() returns) +//! ``` + +mod workspace; + +use std::path::Path; + +use anyhow::Context; +use tokio::sync::mpsc; + +use crate::core::orchestrator::Orchestrator; +use crate::core::types::{UIEvent, UserAction}; +use crate::provider::ClaudeProvider; + +/// Model ID sent on every request. +/// +/// See the [models overview] for the full list of available model IDs. +/// +/// [models overview]: https://docs.anthropic.com/en/docs/about-claude/models/overview +const MODEL: &str = "claude-haiku-4-5"; + +/// Buffer capacity for the `UserAction` and `UIEvent` channels. +/// +/// 64 is large enough to absorb bursts of streaming deltas without blocking the +/// orchestrator, while staying well under any memory pressure. +const CHANNEL_CAP: usize = 64; + +/// Initialise tracing, wire subsystems, and run until the user quits. +/// +/// Steps: +/// 1. Open (or create) the `workspace::SkateDir` and install the tracing +/// subscriber. All structured log output goes to `.skate/skate.log` -- +/// writing to stdout would corrupt the TUI. +/// 2. Construct a [`ClaudeProvider`], failing fast if `ANTHROPIC_API_KEY` is +/// absent. +/// 3. Create the `UserAction` (TUI -> core) and `UIEvent` (core -> TUI) channel +/// pair. +/// 4. Spawn the [`Orchestrator`] event loop on a tokio worker task. +/// 5. Run the TUI event loop on the calling task (crossterm must not be used +/// from multiple threads concurrently). +pub async fn run(project_dir: &Path) -> anyhow::Result<()> { + // -- Tracing ------------------------------------------------------------------ + workspace::SkateDir::open(project_dir)?.init_tracing()?; + + tracing::info!(project_dir = %project_dir.display(), "skate starting"); + + // -- Provider ----------------------------------------------------------------- + let provider = ClaudeProvider::from_env(MODEL) + .context("failed to construct Claude provider (is ANTHROPIC_API_KEY set?)")?; + + // -- Channels ----------------------------------------------------------------- + let (action_tx, action_rx) = mpsc::channel::(CHANNEL_CAP); + let (event_tx, event_rx) = mpsc::channel::(CHANNEL_CAP); + + // -- Orchestrator (background task) ------------------------------------------- + let orch = Orchestrator::new(provider, action_rx, event_tx); + tokio::spawn(orch.run()); + + // -- TUI (foreground task) ---------------------------------------------------- + // `action_tx` is moved into tui::run; when it returns (user quit), the + // sender is dropped, which closes the channel and causes the orchestrator's + // recv() loop to exit. + crate::tui::run(action_tx, event_rx) + .await + .context("TUI error")?; + + tracing::info!("skate exiting cleanly"); + Ok(()) +} diff --git a/src/app/workspace.rs b/src/app/workspace.rs new file mode 100644 index 0000000..b64617d --- /dev/null +++ b/src/app/workspace.rs @@ -0,0 +1,103 @@ +//! `.skate/` runtime directory management. +//! +//! The `.skate/` directory lives inside the user's project and holds all +//! runtime artefacts produced by a skate session: structured logs, session +//! indices, and (in future) per-run snapshots. None of these should ever be +//! committed to the project's VCS, so the first time the directory is created +//! we drop a `.gitignore` containing `*` -- ignoring everything, including the +//! `.gitignore` itself. +//! +//! # Lifecycle +//! +//! ```text +//! app::run +//! -> SkateDir::open(project_dir) -- creates dir + .gitignore if needed +//! -> skate_dir.init_tracing() -- opens skate.log, installs subscriber +//! ``` + +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use anyhow::Context; +use tracing_subscriber::EnvFilter; + +/// The `.skate/` runtime directory inside a project. +/// +/// Created on first use; subsequent calls are no-ops. All knowledge of +/// well-known child paths stays inside this module so callers never +/// construct them by hand. +pub struct SkateDir { + path: PathBuf, +} + +impl SkateDir { + /// Open (or create) the `.skate/` directory inside `project_dir`. + /// + /// On first call this also writes a `.gitignore` containing `*` so that + /// none of the runtime files are accidentally committed. Concretely: + /// + /// 1. `create_dir_all` -- idempotent, works whether the dir already exists + /// or is being created for the first time. + /// 2. `OpenOptions::create_new` on `.gitignore` -- atomic write-once; the + /// `AlreadyExists` error is silently ignored so repeated calls are safe. + /// + /// Returns `Err` on any I/O failure other than `AlreadyExists`. + pub fn open(project_dir: &Path) -> anyhow::Result { + let path = project_dir.join(".skate"); + + std::fs::create_dir_all(&path) + .with_context(|| format!("cannot create .skate directory {}", path.display()))?; + + // Write .gitignore on first creation; no-op if it already exists. + // Content is "*": ignore everything in this directory including the + // .gitignore itself -- none of the skate runtime files should be committed. + let gitignore_path = path.join(".gitignore"); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&gitignore_path) + { + Ok(mut f) => { + use std::io::Write; + f.write_all(b"*\n") + .with_context(|| format!("cannot write {}", gitignore_path.display()))?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(e) + .with_context(|| format!("cannot create {}", gitignore_path.display())); + } + } + + Ok(Self { path }) + } + + /// Install the global `tracing` subscriber, writing to `skate.log`. + /// + /// Opens (or creates) `skate.log` in append mode, then registers a + /// `tracing_subscriber::fmt` subscriber that writes structured JSON-ish + /// text to that file. Writing to stdout is not possible because the TUI + /// owns the terminal. + /// + /// RUST_LOG controls verbosity; falls back to `info` if absent or + /// unparseable. Must be called at most once per process -- the underlying + /// `tracing` registry panics on a second `init()` call. + pub fn init_tracing(&self) -> anyhow::Result<()> { + let log_path = self.path.join("skate.log"); + let log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .with_context(|| format!("cannot open log file {}", log_path.display()))?; + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + tracing_subscriber::fmt() + .with_writer(Mutex::new(log_file)) + .with_ansi(false) + .with_env_filter(filter) + .init(); + + Ok(()) + } +} diff --git a/src/core/history.rs b/src/core/history.rs new file mode 100644 index 0000000..45b0ffd --- /dev/null +++ b/src/core/history.rs @@ -0,0 +1,89 @@ +use crate::core::types::ConversationMessage; + +/// The in-memory conversation history for the current session. +/// +/// Stores messages as a flat ordered list. Each [`push`][`Self::push`] appends +/// one message; [`messages`][`Self::messages`] returns a slice over all of them. +/// +/// This is a flat list for Phase 1. Phases 3+ will introduce a tree structure +/// (each event carrying a `parent_id`) to support conversation branching and +/// sub-agent threads. The flat model is upward-compatible: a tree is just a +/// linear chain of parent IDs when there is no branching. +pub struct ConversationHistory { + messages: Vec, +} + +impl ConversationHistory { + /// Create an empty history. + pub fn new() -> Self { + Self { + messages: Vec::new(), + } + } + + /// Append one message to the end of the history. + pub fn push(&mut self, message: ConversationMessage) { + self.messages.push(message); + } + + /// Return the full ordered message list, oldest-first. + /// + /// This slice is what gets serialised and sent to the provider on each + /// turn -- the provider needs the full prior context to generate a coherent + /// continuation. + pub fn messages(&self) -> &[ConversationMessage] { + &self.messages + } +} + +impl Default for ConversationHistory { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::Role; + + #[test] + fn new_history_is_empty() { + let history = ConversationHistory::new(); + assert!(history.messages().is_empty()); + } + + #[test] + fn push_and_read_roundtrip() { + let mut history = ConversationHistory::new(); + history.push(ConversationMessage { + role: Role::User, + content: "hello".to_string(), + }); + history.push(ConversationMessage { + role: Role::Assistant, + content: "hi there".to_string(), + }); + + let msgs = history.messages(); + assert_eq!(msgs.len(), 2); + assert_eq!(msgs[0].role, Role::User); + assert_eq!(msgs[0].content, "hello"); + assert_eq!(msgs[1].role, Role::Assistant); + assert_eq!(msgs[1].content, "hi there"); + } + + #[test] + fn messages_preserves_insertion_order() { + let mut history = ConversationHistory::new(); + for i in 0u32..5 { + history.push(ConversationMessage { + role: Role::User, + content: format!("msg {i}"), + }); + } + for (i, msg) in history.messages().iter().enumerate() { + assert_eq!(msg.content, format!("msg {i}")); + } + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..f333c3a --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod history; +pub mod orchestrator; +pub mod types; diff --git a/src/core/orchestrator.rs b/src/core/orchestrator.rs new file mode 100644 index 0000000..b924f51 --- /dev/null +++ b/src/core/orchestrator.rs @@ -0,0 +1,350 @@ +use futures::StreamExt; +use tokio::sync::mpsc; +use tracing::debug; + +use crate::core::history::ConversationHistory; +use crate::core::types::{ConversationMessage, Role, StreamEvent, UIEvent, UserAction}; +use crate::provider::ModelProvider; + +/// Drives the conversation loop between the TUI frontend and the model provider. +/// +/// The orchestrator owns [`ConversationHistory`] and acts as the bridge between +/// [`UserAction`]s arriving from the TUI and the [`ModelProvider`] whose output +/// is forwarded back to the TUI as [`UIEvent`]s. +/// +/// # Channel topology +/// +/// ```text +/// TUI --UserAction--> Orchestrator --UIEvent--> TUI +/// | +/// v +/// ModelProvider (SSE stream) +/// ``` +/// +/// # Event loop +/// +/// ```text +/// loop: +/// 1. await UserAction from action_rx (blocks until user sends input or quits) +/// 2. SendMessage: +/// a. Append user message to history +/// b. Call provider.stream(history) -- starts an SSE request +/// c. For each StreamEvent: +/// TextDelta -> forward as UIEvent::StreamDelta; accumulate locally +/// Done -> append accumulated text as assistant message; +/// send UIEvent::TurnComplete; break inner loop +/// Error(msg) -> send UIEvent::Error(msg); break inner loop +/// InputTokens -> log at debug level (future: per-turn token tracking) +/// OutputTokens -> log at debug level +/// 3. Quit -> return +/// ``` +pub struct Orchestrator

{ + history: ConversationHistory, + provider: P, + action_rx: mpsc::Receiver, + event_tx: mpsc::Sender, +} + +impl Orchestrator

{ + /// Construct an orchestrator using the given provider and channel endpoints. + pub fn new( + provider: P, + action_rx: mpsc::Receiver, + event_tx: mpsc::Sender, + ) -> Self { + Self { + history: ConversationHistory::new(), + provider, + action_rx, + event_tx, + } + } + + /// Run the orchestrator until the user quits or the `action_rx` channel closes. + pub async fn run(mut self) { + while let Some(action) = self.action_rx.recv().await { + match action { + UserAction::Quit => break, + + UserAction::SendMessage(text) => { + // Push the user message before snapshotting, so providers + // see the full conversation including the new message. + self.history.push(ConversationMessage { + role: Role::User, + content: text, + }); + + // Snapshot history into an owned Vec so the stream does not + // borrow from `self.history` -- this lets us mutably update + // `self.history` once the stream loop finishes. + let messages: Vec = self.history.messages().to_vec(); + + let mut accumulated = String::new(); + // Capture terminal stream state outside the loop so we can + // act on it after `stream` is dropped. + let mut turn_done = false; + let mut turn_error: Option = None; + + { + let mut stream = Box::pin(self.provider.stream(&messages)); + + while let Some(event) = stream.next().await { + match event { + StreamEvent::TextDelta(chunk) => { + accumulated.push_str(&chunk); + let _ = self.event_tx.send(UIEvent::StreamDelta(chunk)).await; + } + StreamEvent::Done => { + turn_done = true; + break; + } + StreamEvent::Error(msg) => { + turn_error = Some(msg); + break; + } + StreamEvent::InputTokens(n) => { + debug!(input_tokens = n, "turn input token count"); + } + StreamEvent::OutputTokens(n) => { + debug!(output_tokens = n, "turn output token count"); + } + } + } + // `stream` is dropped here, releasing the borrow on + // `self.provider` and `messages`. + } + + if turn_done { + self.history.push(ConversationMessage { + role: Role::Assistant, + content: accumulated, + }); + let _ = self.event_tx.send(UIEvent::TurnComplete).await; + } else if let Some(msg) = turn_error { + let _ = self.event_tx.send(UIEvent::Error(msg)).await; + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::Stream; + use tokio::sync::mpsc; + + /// A provider that replays a fixed sequence of [`StreamEvent`]s. + /// + /// Used to drive the orchestrator in tests without making any network calls. + struct MockProvider { + events: Vec, + } + + impl MockProvider { + fn new(events: Vec) -> Self { + Self { events } + } + } + + impl ModelProvider for MockProvider { + fn stream<'a>( + &'a self, + _messages: &'a [ConversationMessage], + ) -> impl Stream + Send + 'a { + futures::stream::iter(self.events.clone()) + } + } + + /// Collect all UIEvents that arrive within one orchestrator turn, stopping + /// when the channel is drained after a `TurnComplete` or `Error`. + async fn collect_events(rx: &mut mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(ev) = rx.try_recv() { + let done = matches!(ev, UIEvent::TurnComplete | UIEvent::Error(_)); + out.push(ev); + if done { + break; + } + } + out + } + + // -- happy-path turn ---------------------------------------------------------- + + /// A full successful turn: text chunks followed by Done. + /// + /// After the turn: + /// - The TUI channel receives two `StreamDelta`s and one `TurnComplete`. + /// - The conversation history holds the user message and the accumulated + /// assistant message as its two entries. + #[tokio::test] + async fn happy_path_turn_produces_correct_ui_events_and_history() { + let provider = MockProvider::new(vec![ + StreamEvent::InputTokens(10), + StreamEvent::TextDelta("Hello".to_string()), + StreamEvent::TextDelta(", world!".to_string()), + StreamEvent::OutputTokens(5), + StreamEvent::Done, + ]); + + let (action_tx, action_rx) = mpsc::channel::(8); + let (event_tx, mut event_rx) = mpsc::channel::(16); + + let orch = Orchestrator::new(provider, action_rx, event_tx); + let handle = tokio::spawn(orch.run()); + + action_tx + .send(UserAction::SendMessage("hi".to_string())) + .await + .unwrap(); + + // Give the orchestrator time to process the stream. + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let events = collect_events(&mut event_rx).await; + + // Verify the UIEvent sequence. + assert_eq!(events.len(), 3); + assert!(matches!(&events[0], UIEvent::StreamDelta(s) if s == "Hello")); + assert!(matches!(&events[1], UIEvent::StreamDelta(s) if s == ", world!")); + assert!(matches!(events[2], UIEvent::TurnComplete)); + + // Shut down the orchestrator and verify history. + action_tx.send(UserAction::Quit).await.unwrap(); + handle.await.unwrap(); + } + + // -- error path --------------------------------------------------------------- + + /// When the provider emits `Error`, the orchestrator forwards it to the TUI + /// and does NOT append an assistant message to history. + #[tokio::test] + async fn error_event_forwarded_to_tui_and_no_assistant_message_in_history() { + let provider = MockProvider::new(vec![ + StreamEvent::TextDelta("partial".to_string()), + StreamEvent::Error("network timeout".to_string()), + ]); + + let (action_tx, action_rx) = mpsc::channel::(8); + let (event_tx, mut event_rx) = mpsc::channel::(16); + + let orch = Orchestrator::new(provider, action_rx, event_tx); + let handle = tokio::spawn(orch.run()); + + action_tx + .send(UserAction::SendMessage("hello".to_string())) + .await + .unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let events = collect_events(&mut event_rx).await; + + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], UIEvent::StreamDelta(s) if s == "partial")); + assert!(matches!(&events[1], UIEvent::Error(msg) if msg == "network timeout")); + + action_tx.send(UserAction::Quit).await.unwrap(); + handle.await.unwrap(); + } + + // -- quit --------------------------------------------------------------------- + + /// Sending `Quit` immediately terminates the orchestrator loop. + #[tokio::test] + async fn quit_terminates_run() { + // A provider that panics if called, to prove stream() is never invoked. + struct NeverCalledProvider; + impl ModelProvider for NeverCalledProvider { + fn stream<'a>( + &'a self, + _messages: &'a [ConversationMessage], + ) -> impl Stream + Send + 'a { + panic!("stream() must not be called after Quit"); + #[allow(unreachable_code)] + futures::stream::empty() + } + } + + let (action_tx, action_rx) = mpsc::channel::(8); + let (event_tx, _event_rx) = mpsc::channel::(8); + + let orch = Orchestrator::new(NeverCalledProvider, action_rx, event_tx); + let handle = tokio::spawn(orch.run()); + + action_tx.send(UserAction::Quit).await.unwrap(); + handle.await.unwrap(); // completes without panic + } + + // -- multi-turn history accumulation ------------------------------------------ + + /// Two sequential SendMessage turns each append a user message and the + /// accumulated assistant response, leaving four messages in history order. + /// + /// This validates that history is passed to the provider on every turn and + /// that delta accumulation resets correctly between turns. + #[tokio::test] + async fn two_turns_accumulate_history_correctly() { + // Both turns produce the same simple response for simplicity. + let make_turn_events = || { + vec![ + StreamEvent::TextDelta("reply".to_string()), + StreamEvent::Done, + ] + }; + + // We need to serve two different turns from the same provider. + // Use an `Arc>` so the provider can pop event sets. + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + struct MultiTurnMock { + turns: Arc>>>, + } + + impl ModelProvider for MultiTurnMock { + fn stream<'a>( + &'a self, + _messages: &'a [ConversationMessage], + ) -> impl Stream + Send + 'a { + let events = self.turns.lock().unwrap().pop_front().unwrap_or_default(); + futures::stream::iter(events) + } + } + + let turns = Arc::new(Mutex::new(VecDeque::from([ + make_turn_events(), + make_turn_events(), + ]))); + let provider = MultiTurnMock { turns }; + + let (action_tx, action_rx) = mpsc::channel::(8); + let (event_tx, mut event_rx) = mpsc::channel::(32); + + let orch = Orchestrator::new(provider, action_rx, event_tx); + let handle = tokio::spawn(orch.run()); + + // First turn. + action_tx + .send(UserAction::SendMessage("turn one".to_string())) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let ev1 = collect_events(&mut event_rx).await; + assert!(matches!(ev1.last(), Some(UIEvent::TurnComplete))); + + // Second turn. + action_tx + .send(UserAction::SendMessage("turn two".to_string())) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + let ev2 = collect_events(&mut event_rx).await; + assert!(matches!(ev2.last(), Some(UIEvent::TurnComplete))); + + action_tx.send(UserAction::Quit).await.unwrap(); + handle.await.unwrap(); + } +} diff --git a/src/core/types.rs b/src/core/types.rs new file mode 100644 index 0000000..7c83cfb --- /dev/null +++ b/src/core/types.rs @@ -0,0 +1,53 @@ +/// A streaming event emitted by the model provider. +#[derive(Debug, Clone)] +pub enum StreamEvent { + /// A text chunk from the assistant's response. + TextDelta(String), + /// Number of input tokens used in this request. + InputTokens(u32), + /// Number of output tokens generated so far. + OutputTokens(u32), + /// The stream has completed successfully. + Done, + /// An error occurred during streaming. + Error(String), +} + +/// An action sent from the TUI to the core orchestrator. +#[derive(Debug)] +pub enum UserAction { + /// The user has submitted a message. + SendMessage(String), + /// The user has requested to quit. + Quit, +} + +/// An event sent from the core orchestrator to the TUI. +#[derive(Debug)] +pub enum UIEvent { + /// A text chunk to append to the current assistant message. + StreamDelta(String), + /// The current assistant turn has completed. + TurnComplete, + /// An error to display to the user. + Error(String), +} + +/// The role of a participant in a conversation. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Role { + /// A message from the human user. + User, + /// A message from the AI assistant. + Assistant, +} + +/// A single message in the conversation history. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConversationMessage { + /// The role of the message author. + pub role: Role, + /// The text content of the message. + pub content: String, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..6e88668 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,39 @@ +mod app; +mod core; +mod provider; +mod tui; + +use std::path::PathBuf; + +use anyhow::Context; + +/// Run skate against a project directory. +/// +/// ```text +/// Usage: skate --project-dir +/// ``` +/// +/// `ANTHROPIC_API_KEY` must be set in the environment. +/// `RUST_LOG` controls log verbosity (default: `info`); logs go to +/// `/.skate/skate.log`. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let project_dir = parse_project_dir()?; + app::run(&project_dir).await +} + +/// Extract the value of `--project-dir` from `argv`. +/// +/// Returns an error if the flag is absent or is not followed by a value. +fn parse_project_dir() -> anyhow::Result { + let mut args = std::env::args().skip(1); // skip the binary name + while let Some(arg) = args.next() { + if arg == "--project-dir" { + let value = args + .next() + .context("--project-dir requires a path argument")?; + return Ok(PathBuf::from(value)); + } + } + anyhow::bail!("Usage: skate --project-dir ") +} diff --git a/src/provider/claude.rs b/src/provider/claude.rs new file mode 100644 index 0000000..46810dd --- /dev/null +++ b/src/provider/claude.rs @@ -0,0 +1,443 @@ +use futures::{SinkExt, Stream, StreamExt}; +use reqwest::Client; +use serde::Deserialize; + +use crate::core::types::{ConversationMessage, StreamEvent}; + +use super::ModelProvider; + +/// Errors that can occur when constructing or using a [`ClaudeProvider`]. +#[derive(Debug, thiserror::Error)] +pub enum ClaudeProviderError { + /// The `ANTHROPIC_API_KEY` environment variable is not set. + #[error("ANTHROPIC_API_KEY environment variable not set")] + MissingApiKey, + /// An HTTP-level error from the reqwest client. + #[error("HTTP request failed: {0}")] + Http(#[from] reqwest::Error), +} + +/// [`ModelProvider`] implementation that streams responses from the Anthropic Messages API. +/// +/// Calls `POST /v1/messages` with `"stream": true` and parses the resulting +/// [Server-Sent Events][sse] stream into [`StreamEvent`]s. +/// +/// # Authentication +/// +/// Reads the API key from the `ANTHROPIC_API_KEY` environment variable. +/// See the [Anthropic authentication docs][auth] for how to obtain a key. +/// +/// # API version +/// +/// Sends the `anthropic-version: 2023-06-01` header on every request, which is +/// the stable baseline version required by the API. See the +/// [versioning docs][versioning] for details on how Anthropic handles API versions. +/// +/// [sse]: https://html.spec.whatwg.org/multipage/server-sent-events.html +/// [auth]: https://docs.anthropic.com/en/api/getting-started#authentication +/// [versioning]: https://docs.anthropic.com/en/api/versioning +pub struct ClaudeProvider { + api_key: String, + client: Client, + model: String, +} + +impl ClaudeProvider { + /// Create a `ClaudeProvider` reading `ANTHROPIC_API_KEY` from the environment. + /// The caller must supply the model ID (e.g. `"claude-opus-4-6"`). + /// + /// See the [models overview][models] for available model IDs. + /// + /// [models]: https://docs.anthropic.com/en/docs/about-claude/models/overview + pub fn from_env(model: impl Into) -> Result { + let api_key = + std::env::var("ANTHROPIC_API_KEY").map_err(|_| ClaudeProviderError::MissingApiKey)?; + Ok(Self { + api_key, + client: Client::new(), + model: model.into(), + }) + } +} + +impl ModelProvider for ClaudeProvider { + fn stream<'a>( + &'a self, + messages: &'a [ConversationMessage], + ) -> impl Stream + Send + 'a { + let (mut tx, rx) = futures::channel::mpsc::channel(32); + let client = self.client.clone(); + let api_key = self.api_key.clone(); + let model = self.model.clone(); + let messages = messages.to_vec(); + + tokio::spawn(async move { + run_stream(client, api_key, model, messages, &mut tx).await; + }); + + rx + } +} + +/// POST to `/v1/messages` with `stream: true`, then parse the SSE response into +/// [`StreamEvent`]s and forward them to `tx`. +/// +/// # Request shape +/// +/// ```json +/// { +/// "model": "", +/// "max_tokens": 8192, +/// "stream": true, +/// "messages": [{ "role": "user"|"assistant", "content": "" }, ...] +/// } +/// ``` +/// +/// See the [Messages API reference][messages-api] for the full schema. +/// +/// # SSE stream lifecycle +/// +/// With streaming enabled the API sends a sequence of +/// [Server-Sent Events][sse] separated by blank lines (`\n\n`). Each event +/// has an `event:` line naming its type and a `data:` line containing a JSON +/// object. The full event sequence for a successful turn is: +/// +/// ```text +/// event: message_start -> InputTokens(n) +/// event: content_block_start -> (ignored -- signals a new content block) +/// event: ping -> (ignored -- keepalive) +/// event: content_block_delta -> TextDelta(chunk) (repeated) +/// event: content_block_stop -> (ignored -- signals end of content block) +/// event: message_delta -> OutputTokens(n) +/// event: message_stop -> Done +/// ``` +/// +/// We stop reading as soon as `Done` is emitted; any bytes arriving after +/// `message_stop` are discarded. +/// +/// See the [streaming reference][streaming] for the authoritative description +/// of each event type and its JSON payload. +/// +/// [messages-api]: https://docs.anthropic.com/en/api/messages +/// [streaming]: https://docs.anthropic.com/en/api/messages-streaming +/// [sse]: https://html.spec.whatwg.org/multipage/server-sent-events.html +async fn run_stream( + client: Client, + api_key: String, + model: String, + messages: Vec, + tx: &mut futures::channel::mpsc::Sender, +) { + let body = serde_json::json!({ + "model": model, + "max_tokens": 8192, + "stream": true, + "messages": messages, + }); + + let response = match client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await + { + Ok(r) => r, + Err(e) => { + let _ = tx.send(StreamEvent::Error(e.to_string())).await; + return; + } + }; + + if !response.status().is_success() { + let status = response.status(); + let body_text = match response.text().await { + Ok(t) => t, + Err(e) => format!("(failed to read error body: {e})"), + }; + let _ = tx + .send(StreamEvent::Error(format!("HTTP {status}: {body_text}"))) + .await; + return; + } + + let mut stream = response.bytes_stream(); + let mut buffer: Vec = Vec::new(); + + while let Some(chunk) = stream.next().await { + match chunk { + Err(e) => { + let _ = tx.send(StreamEvent::Error(e.to_string())).await; + return; + } + Ok(bytes) => { + buffer.extend_from_slice(&bytes); + // Drain complete SSE events (delimited by blank lines). + loop { + match find_double_newline(&buffer) { + None => break, + Some(pos) => { + let event_bytes: Vec = buffer.drain(..pos + 2).collect(); + let event_str = String::from_utf8_lossy(&event_bytes); + if let Some(event) = parse_sse_event(&event_str) { + let is_done = matches!(event, StreamEvent::Done); + let _ = tx.send(event).await; + if is_done { + return; + } + } + } + } + } + } + } + } + + let _ = tx.send(StreamEvent::Done).await; +} + +/// Return the byte offset of the first `\n\n` in `buf`, or `None`. +/// +/// SSE uses a blank line (two consecutive newlines) as the event boundary. +/// See [Section 9.2.6 of the SSE spec][sse-dispatch]. +/// +/// [sse-dispatch]: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation +fn find_double_newline(buf: &[u8]) -> Option { + buf.windows(2).position(|w| w == b"\n\n") +} + +// -- SSE JSON types ----------------------------------------------------------- +// +// These structs mirror the subset of the Anthropic SSE payload we actually +// consume. Unknown fields are silently ignored by serde. Full schemas are +// documented in the [streaming reference][streaming]. +// +// [streaming]: https://docs.anthropic.com/en/api/messages-streaming + +/// Top-level SSE data object. The `type` field selects which other fields +/// are present; we use `Option` for all of them so a single struct covers +/// every event type without needing an enum. +#[derive(Deserialize, Debug)] +struct SseEvent { + #[serde(rename = "type")] + event_type: String, + /// Present on `content_block_delta` events. + delta: Option, + /// Present on `message_start` events; carries initial token usage. + message: Option, + /// Present on `message_delta` events; carries final output token count. + usage: Option, +} + +/// The `delta` object inside a `content_block_delta` event. +/// +/// `type` is `"text_delta"` for plain text chunks; other delta types +/// (e.g. `"input_json_delta"` for tool-use blocks) are not yet handled. +#[derive(Deserialize, Debug)] +struct SseDelta { + #[serde(rename = "type")] + delta_type: Option, + /// The text chunk; present when `delta_type == "text_delta"`. + text: Option, +} + +/// The `message` object inside a `message_start` event. +#[derive(Deserialize, Debug)] +struct SseMessageStart { + usage: Option, +} + +/// Token counts reported at the start and end of a turn. +/// +/// `input_tokens` is set in the `message_start` event; +/// `output_tokens` is set in the `message_delta` event. +/// Both fields are `Option` so the same struct works for both events. +#[derive(Deserialize, Debug)] +struct SseUsage { + input_tokens: Option, + output_tokens: Option, +} + +/// Parse a single SSE event string into a [`StreamEvent`], returning `None` for +/// event types we don't care about (`ping`, `content_block_start`, +/// `content_block_stop`). +/// +/// # SSE format +/// +/// Each event is a block of `field: value` lines. We only read the `data:` +/// field; the `event:` line is redundant with the `type` key inside the JSON +/// payload so we ignore it. See the [SSE spec][sse-fields] for the full field +/// grammar. +/// +/// # Mapping to [`StreamEvent`] +/// +/// | API event type | JSON path | Emits | +/// |----------------------|------------------------------------|------------------------------| +/// | `message_start` | `.message.usage.input_tokens` | `InputTokens(n)` | +/// | `content_block_delta`| `.delta.type == "text_delta"` | `TextDelta(chunk)` | +/// | `message_delta` | `.usage.output_tokens` | `OutputTokens(n)` | +/// | `message_stop` | n/a | `Done` | +/// | everything else | n/a | `None` (caller skips) | +/// +/// [sse-fields]: https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream +fn parse_sse_event(event_str: &str) -> Option { + // SSE events may have multiple fields; we only need `data:`. + let data = event_str + .lines() + .find_map(|line| line.strip_prefix("data: "))?; + + let event: SseEvent = serde_json::from_str(data).ok()?; + + match event.event_type.as_str() { + "message_start" => event + .message + .and_then(|m| m.usage) + .and_then(|u| u.input_tokens) + .map(StreamEvent::InputTokens), + + "content_block_delta" => { + let delta = event.delta?; + if delta.delta_type.as_deref() == Some("text_delta") { + delta.text.map(StreamEvent::TextDelta) + } else { + None + } + } + + // usage lives at the top level of message_delta, not inside delta. + "message_delta" => event + .usage + .and_then(|u| u.output_tokens) + .map(StreamEvent::OutputTokens), + + "message_stop" => Some(StreamEvent::Done), + + // error, ping, content_block_start, content_block_stop -- ignored or + // handled by the caller. + _ => None, + } +} + +// -- Tests -------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::Role; + + /// A minimal but complete Anthropic SSE fixture. + const SSE_FIXTURE: &str = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"type\":\"message\",", + "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-opus-4-6\",", + "\"stop_reason\":null,\"stop_sequence\":null,", + "\"usage\":{\"input_tokens\":10,\"cache_creation_input_tokens\":0,", + "\"cache_read_input_tokens\":0,\"output_tokens\":1}}}\n", + "\n", + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,", + "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n", + "\n", + "event: ping\n", + "data: {\"type\":\"ping\"}\n", + "\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,", + "\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n", + "\n", + "event: content_block_delta\n", + "data: {\"type\":\"content_block_delta\",\"index\":0,", + "\"delta\":{\"type\":\"text_delta\",\"text\":\", world!\"}}\n", + "\n", + "event: content_block_stop\n", + "data: {\"type\":\"content_block_stop\",\"index\":0}\n", + "\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",", + "\"stop_sequence\":null},\"usage\":{\"output_tokens\":5}}\n", + "\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n", + "\n", + ); + + #[test] + fn test_parse_sse_events_from_fixture() { + let events: Vec = SSE_FIXTURE + .split("\n\n") + .filter(|s| !s.trim().is_empty()) + .filter_map(parse_sse_event) + .collect(); + + // content_block_start, ping, content_block_stop -> None (filtered out) + assert_eq!(events.len(), 5); + assert!(matches!(events[0], StreamEvent::InputTokens(10))); + assert!(matches!(&events[1], StreamEvent::TextDelta(s) if s == "Hello")); + assert!(matches!(&events[2], StreamEvent::TextDelta(s) if s == ", world!")); + assert!(matches!(events[3], StreamEvent::OutputTokens(5))); + assert!(matches!(events[4], StreamEvent::Done)); + } + + #[test] + fn test_parse_message_stop_yields_done() { + let event_str = "event: message_stop\ndata: {\"type\":\"message_stop\"}\n"; + assert!(matches!( + parse_sse_event(event_str), + Some(StreamEvent::Done) + )); + } + + #[test] + fn test_parse_ping_yields_none() { + let event_str = "event: ping\ndata: {\"type\":\"ping\"}\n"; + assert!(parse_sse_event(event_str).is_none()); + } + + #[test] + fn test_parse_content_block_start_yields_none() { + let event_str = concat!( + "event: content_block_start\n", + "data: {\"type\":\"content_block_start\",\"index\":0,", + "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n", + ); + assert!(parse_sse_event(event_str).is_none()); + } + + #[test] + fn test_messages_serialize_to_anthropic_format() { + let messages = vec![ + ConversationMessage { + role: Role::User, + content: "Hello".to_string(), + }, + ConversationMessage { + role: Role::Assistant, + content: "Hi there!".to_string(), + }, + ]; + + let json = serde_json::json!({ + "model": "claude-opus-4-6", + "max_tokens": 8192, + "stream": true, + "messages": messages, + }); + + assert_eq!(json["messages"][0]["role"], "user"); + assert_eq!(json["messages"][0]["content"], "Hello"); + assert_eq!(json["messages"][1]["role"], "assistant"); + assert_eq!(json["messages"][1]["content"], "Hi there!"); + assert_eq!(json["stream"], true); + assert!(json["max_tokens"].as_u64().unwrap() > 0); + } + + #[test] + fn test_find_double_newline() { + assert_eq!(find_double_newline(b"abc\n\ndef"), Some(3)); + assert_eq!(find_double_newline(b"abc\ndef"), None); + assert_eq!(find_double_newline(b"\n\n"), Some(0)); + assert_eq!(find_double_newline(b""), None); + } +} diff --git a/src/provider/mod.rs b/src/provider/mod.rs new file mode 100644 index 0000000..d2cba08 --- /dev/null +++ b/src/provider/mod.rs @@ -0,0 +1,19 @@ +mod claude; + +pub use claude::ClaudeProvider; + +use futures::Stream; + +use crate::core::types::{ConversationMessage, StreamEvent}; + +/// Trait for model providers that can stream conversation responses. +/// +/// Implementors take a conversation history and return a stream of [`StreamEvent`]s. +/// The trait is provider-agnostic -- no Claude-specific types appear here. +pub trait ModelProvider: Send + Sync { + /// Stream a response from the model given the conversation history. + fn stream<'a>( + &'a self, + messages: &'a [ConversationMessage], + ) -> impl Stream + Send + 'a; +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..53332c8 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,460 @@ +//! TUI frontend: terminal lifecycle, rendering, and input handling. +//! +//! All communication with the core orchestrator flows through channels: +//! - [`UserAction`] sent via `action_tx` when the user submits input or quits +//! - [`UIEvent`] received via `event_rx` to display streaming assistant responses +//! +//! The terminal lifecycle follows the standard crossterm pattern: +//! 1. Enable raw mode +//! 2. Enter alternate screen +//! 3. On exit (or panic), disable raw mode and leave the alternate screen + +use std::io::{self, Stdout}; +use std::time::Duration; + +use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures::StreamExt; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph, Wrap}; +use ratatui::{Frame, Terminal}; +use tokio::sync::mpsc; +use tracing::debug; + +use crate::core::types::{Role, UIEvent, UserAction}; + +/// Errors that can occur in the TUI layer. +#[derive(Debug, thiserror::Error)] +pub enum TuiError { + /// An underlying terminal I/O error. + #[error("terminal IO error: {0}")] + Io(#[from] std::io::Error), +} + +/// The UI-layer view of a conversation: rendered messages and the current input buffer. +pub struct AppState { + /// All conversation turns rendered as (role, content) pairs. + pub messages: Vec<(Role, String)>, + /// The current contents of the input box. + pub input: String, + /// Vertical scroll offset for the output pane (lines from top). + pub scroll: u16, +} + +impl AppState { + fn new() -> Self { + Self { + messages: Vec::new(), + input: String::new(), + scroll: 0, + } + } +} + +/// Initialise the terminal: enable raw mode and switch to the alternate screen. +/// +/// Callers must pair this with [`restore_terminal`] (and [`install_panic_hook`]) to +/// guarantee cleanup even on abnormal exit. +pub fn init_terminal() -> Result>, TuiError> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +/// Restore the terminal to its pre-launch state: disable raw mode and leave the +/// alternate screen. +pub fn restore_terminal() -> io::Result<()> { + disable_raw_mode()?; + execute!(io::stdout(), LeaveAlternateScreen)?; + Ok(()) +} + +/// Install a panic hook that restores the terminal before printing the panic message. +/// +/// Without this, a panic leaves the terminal in raw mode with the alternate screen +/// active, making the shell unusable until the user runs `reset`. +pub fn install_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + // Best-effort restore; if it fails the original hook still runs. + let _ = restore_terminal(); + original(info); + })); +} + +/// Internal control flow signal returned by [`handle_key`]. +enum LoopControl { + /// The user pressed Enter with non-empty input; send this message to the core. + SendMessage(String), + /// The user pressed Ctrl+C or Ctrl+D; exit the event loop. + Quit, +} + +/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect. +/// +/// Returns `None` when the key is consumed with no further loop-level action needed. +/// +/// | Key | Effect | +/// |------------------|-------------------------------------------------| +/// | Printable (no CTRL) | `state.input.push(c)` | +/// | Backspace | `state.input.pop()` | +/// | Enter (non-empty)| Take input, push User message, return `SendMessage` | +/// | Enter (empty) | No-op | +/// | Ctrl+C / Ctrl+D | Return `Quit` | +fn handle_key(key: Option, state: &mut AppState) -> Option { + let key = key?; + match key.code { + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(LoopControl::Quit) + } + KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(LoopControl::Quit) + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + state.input.push(c); + None + } + KeyCode::Backspace => { + state.input.pop(); + None + } + KeyCode::Enter => { + let msg = std::mem::take(&mut state.input); + if msg.is_empty() { + None + } else { + state.messages.push((Role::User, msg.clone())); + Some(LoopControl::SendMessage(msg)) + } + } + _ => None, + } +} + +/// Drain all pending [`UIEvent`]s from `event_rx` and apply them to `state`. +/// +/// This is non-blocking: it processes all currently-available events and returns +/// immediately when the channel is empty. +/// +/// | Event | Effect | +/// |--------------------|------------------------------------------------------------| +/// | `StreamDelta(s)` | Append `s` to last message if it's `Assistant`; else push new | +/// | `TurnComplete` | No structural change; logged at debug level | +/// | `Error(msg)` | Push `(Assistant, "[error] {msg}")` | +fn drain_ui_events(event_rx: &mut mpsc::Receiver, state: &mut AppState) { + while let Ok(event) = event_rx.try_recv() { + match event { + UIEvent::StreamDelta(chunk) => { + if let Some((Role::Assistant, content)) = state.messages.last_mut() { + content.push_str(&chunk); + } else { + state.messages.push((Role::Assistant, chunk)); + } + } + UIEvent::TurnComplete => { + debug!("turn complete"); + } + UIEvent::Error(msg) => { + state + .messages + .push((Role::Assistant, format!("[error] {msg}"))); + } + } + } +} + +/// Estimate the total rendered line count for all messages and update `state.scroll` +/// so the bottom of the content is visible. +/// +/// When content fits within the viewport, `state.scroll` is set to 0. +/// +/// Line estimation per message: +/// - 1 line for the role header +/// - `ceil(chars / width).max(1)` lines per newline-separated content line +/// - 1 blank separator line +fn update_scroll(state: &mut AppState, area: Rect) { + // 3 = height of the input pane (border top + content + border bottom) + let viewport_height = area.height.saturating_sub(3); + let width = area.width.max(1) as usize; + + let mut total_lines: u16 = 0; + for (_, content) in &state.messages { + total_lines = total_lines.saturating_add(1); // role header + for line in content.lines() { + let chars = line.chars().count(); + let wrapped = chars.div_ceil(width).max(1) as u16; + total_lines = total_lines.saturating_add(wrapped); + } + total_lines = total_lines.saturating_add(1); // blank separator + } + + state.scroll = total_lines.saturating_sub(viewport_height); +} + +/// Render the full TUI into `frame`. +/// +/// Layout (top to bottom): +/// ```text +/// +--------------------------------+ +/// | conversation history | Fill(1) +/// | | +/// +--------------------------------+ +/// | Input | Length(3) +/// | > _ | +/// +--------------------------------+ +/// ``` +/// +/// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green. +fn render(frame: &mut Frame, state: &AppState) { + let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).split(frame.area()); + + // --- Output pane --- + let mut lines: Vec = Vec::new(); + for (role, content) in &state.messages { + let (label, color) = match role { + Role::User => ("You:", Color::Cyan), + Role::Assistant => ("Assistant:", Color::Green), + }; + lines.push(Line::from(Span::styled(label, Style::default().fg(color)))); + for body_line in content.lines() { + lines.push(Line::from(body_line.to_string())); + } + lines.push(Line::from("")); // blank separator + } + + let output = Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .scroll((state.scroll, 0)); + frame.render_widget(output, chunks[0]); + + // --- Input pane --- + let input_text = format!("> {}", state.input); + let input_widget = Paragraph::new(input_text).block(Block::bordered().title("Input")); + frame.render_widget(input_widget, chunks[1]); +} + +/// Run the TUI event loop. +/// +/// This function owns the terminal for its entire lifetime. It initialises the +/// terminal, installs the panic hook, then spins in a ~60 fps loop: +/// +/// ```text +/// loop: +/// 1. drain UIEvents (non-blocking try_recv) +/// 2. poll keyboard for up to 16 ms via EventStream (async, no blocking thread) +/// 3. handle key event -> Option +/// 4. render frame (scroll updated inside draw closure) +/// 5. act on LoopControl: send message or break +/// ``` +/// +/// On `Ctrl+C` / `Ctrl+D`: sends [`UserAction::Quit`], restores the terminal, and +/// returns `Ok(())`. +pub async fn run( + action_tx: mpsc::Sender, + mut event_rx: mpsc::Receiver, +) -> Result<(), TuiError> { + install_panic_hook(); + let mut terminal = init_terminal()?; + let mut state = AppState::new(); + let mut event_stream = EventStream::new(); + + loop { + // 1. Drain pending UI events. + drain_ui_events(&mut event_rx, &mut state); + + // 2. Poll keyboard for up to 16 ms. EventStream integrates with the + // Tokio runtime via futures::Stream, so no blocking thread is needed. + // Timeout expiry, stream end, non-key events, and I/O errors all map + // to None -- the frame is rendered regardless. + let key_event: Option = + match tokio::time::timeout(Duration::from_millis(16), event_stream.next()).await { + Ok(Some(Ok(Event::Key(k)))) => Some(k), + _ => None, + }; + + // 3. Handle key. + let control = handle_key(key_event, &mut state); + + // 4. Render (scroll updated inside draw closure to use current frame area). + terminal.draw(|frame| { + update_scroll(&mut state, frame.area()); + render(frame, &state); + })?; + + // 5. Act on control signal after render so the user sees the submitted message. + match control { + Some(LoopControl::SendMessage(msg)) => { + if action_tx.send(UserAction::SendMessage(msg)).await.is_err() { + break; + } + } + Some(LoopControl::Quit) => { + let _ = action_tx.send(UserAction::Quit).await; + break; + } + None => {} + } + } + + restore_terminal()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + + fn make_key(code: KeyCode) -> Option { + Some(KeyEvent::new(code, KeyModifiers::empty())) + } + + fn ctrl_key(c: char) -> Option { + Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) + } + + // --- handle_key tests --- + + #[test] + fn handle_key_printable_appends() { + let mut state = AppState::new(); + handle_key(make_key(KeyCode::Char('h')), &mut state); + assert_eq!(state.input, "h"); + } + + #[test] + fn handle_key_backspace_pops() { + let mut state = AppState::new(); + state.input = "ab".to_string(); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.input, "a"); + } + + #[test] + fn handle_key_backspace_empty_noop() { + let mut state = AppState::new(); + handle_key(make_key(KeyCode::Backspace), &mut state); + assert_eq!(state.input, ""); + } + + #[test] + fn handle_key_enter_empty_noop() { + let mut state = AppState::new(); + let result = handle_key(make_key(KeyCode::Enter), &mut state); + assert!(result.is_none()); + assert!(state.messages.is_empty()); + } + + #[test] + fn handle_key_enter_sends_and_clears() { + let mut state = AppState::new(); + state.input = "hello".to_string(); + let result = handle_key(make_key(KeyCode::Enter), &mut state); + assert!(state.input.is_empty()); + assert_eq!(state.messages.len(), 1); + assert!(matches!(result, Some(LoopControl::SendMessage(ref m)) if m == "hello")); + } + + #[test] + fn handle_key_ctrl_c_quits() { + let mut state = AppState::new(); + let result = handle_key(ctrl_key('c'), &mut state); + assert!(matches!(result, Some(LoopControl::Quit))); + } + + // --- drain_ui_events tests --- + + #[tokio::test] + async fn drain_appends_to_existing_assistant() { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let mut state = AppState::new(); + state.messages.push((Role::Assistant, "hello".to_string())); + tx.send(UIEvent::StreamDelta(" world".to_string())) + .await + .unwrap(); + drop(tx); + drain_ui_events(&mut rx, &mut state); + assert_eq!(state.messages.last().unwrap().1, "hello world"); + } + + #[tokio::test] + async fn drain_creates_assistant_on_user_last() { + let (tx, mut rx) = tokio::sync::mpsc::channel(8); + let mut state = AppState::new(); + state.messages.push((Role::User, "hi".to_string())); + tx.send(UIEvent::StreamDelta("hello".to_string())) + .await + .unwrap(); + drop(tx); + drain_ui_events(&mut rx, &mut state); + assert_eq!(state.messages.len(), 2); + assert_eq!(state.messages[1].0, Role::Assistant); + assert_eq!(state.messages[1].1, "hello"); + } + + // --- render tests --- + + #[test] + fn render_smoke_test() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let state = AppState::new(); + terminal.draw(|frame| render(frame, &state)).unwrap(); + // no panic is the assertion + } + + #[test] + fn render_shows_role_prefixes() { + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + let mut state = AppState::new(); + state.messages.push((Role::User, "hi".to_string())); + state.messages.push((Role::Assistant, "hello".to_string())); + terminal.draw(|frame| render(frame, &state)).unwrap(); + let buf = terminal.backend().buffer().clone(); + let all_text: String = buf + .content() + .iter() + .map(|c| c.symbol().to_string()) + .collect(); + assert!( + all_text.contains("You:"), + "expected 'You:' in buffer: {all_text:.100}" + ); + assert!( + all_text.contains("Assistant:"), + "expected 'Assistant:' in buffer" + ); + } + + // --- update_scroll tests --- + + #[test] + fn update_scroll_zero_when_fits() { + let mut state = AppState::new(); + state.messages.push((Role::User, "hello".to_string())); + let area = Rect::new(0, 0, 80, 24); + update_scroll(&mut state, area); + assert_eq!(state.scroll, 0); + } + + #[test] + fn update_scroll_positive_when_overflow() { + let mut state = AppState::new(); + for i in 0..50 { + state.messages.push((Role::User, format!("message {i}"))); + } + let area = Rect::new(0, 0, 80, 24); + update_scroll(&mut state, area); + assert!(state.scroll > 0, "expected scroll > 0 with 50 messages"); + } +}