From 3f49e11b21f3a1b6f6688a696518a0574551ee71 Mon Sep 17 00:00:00 2001 From: Jonas H Date: Thu, 11 Jun 2026 07:59:05 +0200 Subject: [PATCH] Initial commit: pass-through proxy TUI for Claude Code streams Co-Authored-By: Claude Fable 5 --- .claude/settings.json | 5 + .gitignore | 1 + CLAUDE.md | 61 + Cargo.lock | 2446 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 15 + README.md | 62 ++ src/app.rs | 369 +++++++ src/main.rs | 38 + src/proxy.rs | 129 +++ src/sse.rs | 78 ++ src/ui.rs | 507 +++++++++ 11 files changed, 3711 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/main.rs create mode 100644 src/proxy.rs create mode 100644 src/sse.rs create mode 100644 src/ui.rs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1f73a2b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "worktree": { + "bgIsolation": "none" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..38d6ea2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# claude-thinking + +TUI that displays Claude Code's API streams token-by-token (thinking, text, tool +calls) by acting as a pass-through proxy: Claude Code points `ANTHROPIC_BASE_URL` +at `127.0.0.1:8484`, we forward everything verbatim to `api.anthropic.com` and +tee SSE responses into the UI. **Never issue API requests of our own** — zero +extra usage is the core constraint of this project. + +## Architecture + +``` +src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode +src/proxy.rs axum fallback handler: buffers request body (for session metadata), + forwards via reqwest, streams response back unbuffered, tees SSE +src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8 +src/app.rs Arc> shared state; Tap = one in-flight tapped request, + translates SSE events → session Entries (Drop closes it out) +src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed +``` + +Data flow: proxy task parses SSE chunks → `Tap::handle()` mutates shared state → +UI thread redraws on its own tick (no channel; just the mutex). + +## Key invariants + +- **Latency-neutral pass-through**: response bytes are forwarded as-is, never + buffered or rewritten. Auth headers pass through untouched. If the tap code + panics or misparses, the proxy must still relay bytes (tee is best-effort). +- **`accept-encoding` is stripped** from forwarded requests so the upstream + sends identity encoding we can parse in transit. Don't "fix" that. +- Hop-by-hop headers (`content-length`, `transfer-encoding`, etc.) are stripped + both directions; hyper re-frames. +- Sessions are keyed by the UUID after `session_` in request `metadata.user_id`. + Concurrent requests (subagents) share a session but each `Tap` tracks its own + current entry index — entries/sessions are append-only, so indices stay stable. +- Tool input streams as raw JSON fragments; pretty-printed only on + `content_block_stop`. Text re-renders markdown every frame, so partial + markdown self-heals. + +## Gotchas + +- `tui-markdown` is pinned `=0.3.5`: later versions use `ratatui-core` (0.30 + alpha types), incompatible with ratatui 0.29. +- ratatui needs feature `unstable-rendered-line-info` for `Paragraph::line_count` + (used for follow/auto-scroll). +- reqwest is `default-features = false` + `rustls-tls,stream` — don't enable + compression features (would re-add accept-encoding). +- Bind failures (port in use) only surface in the TUI status bar; check for a + stale `claude-thinking` process holding 8484. +- Testing: SSE parser has unit tests (`cargo test`). For a live pass-through + check: `--headless`, then POST to `127.0.0.1:8484/v1/messages` without auth — + a relayed 401 from Anthropic proves the round-trip. The TUI can't run in a + non-tty. + +## Not yet handled (known MVP limits) + +- Non-streaming requests pass through untapped (e.g. `count_tokens`). +- Sessions are never pruned; long sessions re-render fully each frame. +- Tool results only appear once the *next* request fires; if the session ends + right after a tool call, that result is never seen. Output is what Claude + Code sends the model (i.e. post-truncation). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..eb14a2f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2446 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "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 = "ansi-to-tui" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom", + "ratatui", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "claude-thinking" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "futures-util", + "ratatui", + "reqwest", + "serde", + "serde_json", + "tokio", + "tui-markdown", +] + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.0", +] + +[[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 = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[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 = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rstest" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.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", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror 2.0.18", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "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", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-markdown" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10648c25931bfaaf5334ff4e7dc5f3d830e0c50d7b0119b1d5cfe771f540536" +dependencies = [ + "ansi-to-tui", + "itertools 0.14.0", + "pretty_assertions", + "pulldown-cmark", + "ratatui", + "rstest", + "syntect", + "tracing", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[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 = "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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.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.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.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.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.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.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.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.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 = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6cc23fb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "claude-thinking" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.102" +axum = "0.8" +futures-util = "0.3.32" +ratatui = { version = "0.29", features = ["unstable-rendered-line-info"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["full"] } +tui-markdown = "=0.3.5" diff --git a/README.md b/README.md new file mode 100644 index 0000000..057bf53 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# claude-thinking + +A TUI that displays Claude Code's streams token-by-token — thinking, text, and +tool calls — by sitting as a pass-through proxy between Claude Code and the +Anthropic API. + +**No extra usage:** it never issues requests of its own; it observes the SSE +stream of requests Claude Code was already making, forwarding bytes verbatim +and unbuffered. + +``` +claude ──ANTHROPIC_BASE_URL──▶ claude-thinking (127.0.0.1:8484) ──▶ api.anthropic.com + │ + ▼ + TUI: live token feed +``` + +## Usage + +```sh +cargo build --release +./target/release/claude-thinking +``` + +Then point Claude Code at the proxy, either per-shell: + +```sh +export ANTHROPIC_BASE_URL=http://127.0.0.1:8484 +claude +``` + +or globally in `~/.claude/settings.json`: + +```json +{ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8484" } } +``` + +Note: with the global setting, Claude Code can't reach the API while the +proxy isn't running. + +## Keys + +| key | action | +|---|---| +| `q` / `Esc` | quit | +| `Tab` / `Shift-Tab` | switch session | +| `j`/`k`, arrows, PgUp/PgDn | scroll (disables follow) | +| `f` / `G` / `End` | follow live tail | +| `g` / `Home` | jump to top | + +## Display + +- **✻ thinking** — dim italic, streamed token-by-token +- **text** — rendered as markdown (`tui-markdown`) +- **⚙ tool calls** — input JSON pretty-printed on completion, raw fragments while streaming +- **meta** — message boundaries with model, context size, stop reason, token counts +- **errors** — API/stream errors in red + +Sessions are keyed by the Claude Code session ID found in request metadata; +concurrent requests (subagents) tap independently. + +`--headless` runs the proxy without the TUI. `CT_PORT` overrides the port (default 8484). diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..7cf3bac --- /dev/null +++ b/src/app.rs @@ -0,0 +1,369 @@ +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +pub type SharedApp = Arc>; + +/// Display names for the filterable entry kinds, in toggle order. +pub const FILTER_LABELS: [&str; 5] = ["thinking", "text", "tools", "meta", "errors"]; + +pub struct App { + pub sessions: Vec, + pub selected: usize, + pub scroll: usize, + pub follow: bool, + pub status: String, + /// Which entry kinds are visible (indices match `FILTER_LABELS`). + pub filters: [bool; FILTER_LABELS.len()], + /// `Some(selected_row)` while the filter popup is open. + pub filter_popup: Option, + /// Whether the session list panel is expanded. + pub show_sessions: bool, +} + +impl App { + pub fn new() -> Self { + Self { + sessions: Vec::new(), + selected: 0, + scroll: 0, + follow: true, + status: "starting proxy…".into(), + filters: [true; FILTER_LABELS.len()], + filter_popup: None, + show_sessions: true, + } + } +} + +pub fn filter_index(kind: &Kind) -> usize { + match kind { + Kind::Thinking => 0, + Kind::Text => 1, + Kind::Tool { .. } => 2, + Kind::Meta => 3, + Kind::Error => 4, + } +} + +pub struct Session { + pub key: String, + pub model: String, + pub entries: Vec, + pub active: usize, + pub input_tokens: u64, + pub output_tokens: u64, + pub last_activity: Instant, + /// tool_use id → entry index, so results arriving in the *next* request + /// body can be attached to the tool entry they belong to. + pub tool_ids: HashMap, +} + +#[derive(PartialEq)] +pub enum Kind { + Thinking, + Text, + Tool { name: String }, + Meta, + Error, +} + +pub struct Entry { + pub kind: Kind, + pub content: String, + pub done: bool, + /// Tool entries only: the tool_result echoed back in the next request. + pub result: Option, +} + +pub struct ToolResult { + pub content: String, + pub is_error: bool, +} + +impl Entry { + fn meta(content: String) -> Self { + Self { kind: Kind::Meta, content, done: true, result: None } + } +} + +/// Scan a request body's `messages` for `tool_result` blocks and attach them +/// to the tool_use entries recorded in `Session::tool_ids`. Purely passive: +/// only reads bytes that were already flowing through the proxy. +pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) { + let Some(messages) = body.get("messages").and_then(Value::as_array) else { + return; + }; + let mut a = app.lock().unwrap(); + let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else { + return; + }; + for m in messages { + let Some(blocks) = m.get("content").and_then(Value::as_array) else { + continue; + }; + for b in blocks { + if b.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { + continue; + }; + let Some(idx) = s.tool_ids.remove(id) else { + continue; + }; + if let Some(e) = s.entries.get_mut(idx) { + e.result = Some(ToolResult { + content: flatten_result_content(b.get("content")), + is_error: b.get("is_error").and_then(Value::as_bool).unwrap_or(false), + }); + } + } + } +} + +/// tool_result content is either a plain string or an array of content blocks. +fn flatten_result_content(v: Option<&Value>) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .map(|b| match b.get("type").and_then(Value::as_str) { + Some("text") => b + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + other => format!("[{}]", other.unwrap_or("?")), + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +/// One in-flight API request being observed. Created when a streaming +/// /v1/messages request passes through the proxy; handles its SSE events. +pub struct Tap { + app: SharedApp, + sidx: usize, + cur: Option, +} + +impl Tap { + pub fn new(app: SharedApp, key: String, model: String) -> Self { + let sidx = { + let mut a = app.lock().unwrap(); + let sidx = match a.sessions.iter().position(|s| s.key == key) { + Some(i) => i, + None => { + a.sessions.push(Session { + key, + model: model.clone(), + entries: Vec::new(), + active: 0, + input_tokens: 0, + output_tokens: 0, + last_activity: Instant::now(), + tool_ids: HashMap::new(), + }); + a.sessions.len() - 1 + } + }; + a.sessions[sidx].active += 1; + a.sessions[sidx].last_activity = Instant::now(); + sidx + }; + Self { app, sidx, cur: None } + } + + pub fn handle(&mut self, ev: &str, d: &Value) { + let mut a = self.app.lock().unwrap(); + let s = &mut a.sessions[self.sidx]; + s.last_activity = Instant::now(); + match ev { + "message_start" => { + if let Some(m) = d.pointer("/message/model").and_then(Value::as_str) { + s.model = m.to_string(); + } + let u = |k: &str| { + d.pointer(&format!("/message/usage/{k}")) + .and_then(Value::as_u64) + .unwrap_or(0) + }; + let ctx = u("input_tokens") + + u("cache_read_input_tokens") + + u("cache_creation_input_tokens"); + s.input_tokens = ctx; + s.entries + .push(Entry::meta(format!("▶ {} · context {}", s.model, fmt_tokens(ctx)))); + } + "content_block_start" => { + let bt = d + .pointer("/content_block/type") + .and_then(Value::as_str) + .unwrap_or(""); + let kind = match bt { + "thinking" => Kind::Thinking, + "redacted_thinking" => Kind::Thinking, + "text" => Kind::Text, + "tool_use" | "server_tool_use" | "mcp_tool_use" => { + // Remember the id so the result (echoed back in the + // next request body) can find this entry. + if let Some(id) = + d.pointer("/content_block/id").and_then(Value::as_str) + { + s.tool_ids.insert(id.to_string(), s.entries.len()); + } + Kind::Tool { + name: d + .pointer("/content_block/name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(), + } + } + other => { + s.entries.push(Entry::meta(format!("[{other}]"))); + self.cur = Some(s.entries.len() - 1); + return; + } + }; + let content = if bt == "redacted_thinking" { + "[redacted]".to_string() + } else { + String::new() + }; + s.entries.push(Entry { kind, content, done: false, result: None }); + self.cur = Some(s.entries.len() - 1); + } + "content_block_delta" => { + if let Some(i) = self.cur { + let text = match d.pointer("/delta/type").and_then(Value::as_str) { + Some("thinking_delta") => d.pointer("/delta/thinking"), + Some("text_delta") => d.pointer("/delta/text"), + Some("input_json_delta") => d.pointer("/delta/partial_json"), + _ => None, + }; + if let Some(t) = text.and_then(Value::as_str) { + s.entries[i].content.push_str(t); + } + } + } + "content_block_stop" => { + if let Some(i) = self.cur.take() { + let e = &mut s.entries[i]; + e.done = true; + // Tool input arrives as JSON fragments; pretty-print once complete. + if matches!(e.kind, Kind::Tool { .. }) { + if let Ok(v) = serde_json::from_str::(&e.content) { + if let Ok(p) = serde_json::to_string_pretty(&v) { + e.content = p; + } + } + } + } + } + "message_delta" => { + if let Some(o) = d.pointer("/usage/output_tokens").and_then(Value::as_u64) { + s.output_tokens += o; + } + if let Some(r) = d.pointer("/delta/stop_reason").and_then(Value::as_str) { + s.entries.push(Entry::meta(format!("■ {r}"))); + } + } + "error" => { + let msg = d + .pointer("/error/message") + .and_then(Value::as_str) + .unwrap_or("unknown stream error"); + let etype = d + .pointer("/error/type") + .and_then(Value::as_str) + .unwrap_or("error"); + s.entries.push(Entry { + kind: Kind::Error, + content: format!("✖ {etype}: {msg}"), + done: true, + result: None, + }); + } + _ => {} + } + } +} + +impl Drop for Tap { + fn drop(&mut self) { + let mut a = self.app.lock().unwrap(); + let s = &mut a.sessions[self.sidx]; + s.active = s.active.saturating_sub(1); + if let Some(i) = self.cur.take() { + s.entries[i].done = true; + } + } +} + +pub fn fmt_tokens(n: u64) -> String { + if n >= 1000 { + format!("{:.1}k", n as f64 / 1000.0) + } else { + n.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_result_attaches_to_entry() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + let mut tap = Tap::new(app.clone(), "abc".into(), "claude-x".into()); + tap.handle( + "content_block_start", + &json!({"content_block": {"type": "tool_use", "id": "toolu_01", "name": "Bash"}}), + ); + tap.handle( + "content_block_delta", + &json!({"delta": {"type": "input_json_delta", "partial_json": "{\"command\":\"ls\"}"}}), + ); + tap.handle("content_block_stop", &json!({})); + drop(tap); + + // Next request echoes the result back (string and block-array forms). + attach_tool_results( + &app, + "abc", + &json!({"messages": [{"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_01", + "content": [{"type": "text", "text": "file_a\nfile_b"}], + "is_error": false} + ]}]}), + ); + + let a = app.lock().unwrap(); + let e = &a.sessions[0].entries[0]; + assert!(matches!(e.kind, Kind::Tool { .. })); + let r = e.result.as_ref().expect("result attached"); + assert_eq!(r.content, "file_a\nfile_b"); + assert!(!r.is_error); + assert!(a.sessions[0].tool_ids.is_empty(), "id consumed"); + } + + #[test] + fn unknown_tool_id_is_ignored() { + let app: SharedApp = Arc::new(Mutex::new(App::new())); + drop(Tap::new(app.clone(), "abc".into(), "claude-x".into())); + attach_tool_results( + &app, + "abc", + &json!({"messages": [{"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "toolu_nope", "content": "x"} + ]}]}), + ); + assert!(app.lock().unwrap().sessions[0].entries.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c3490fa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,38 @@ +mod app; +mod proxy; +mod sse; +mod ui; + +use app::App; +use std::sync::{Arc, Mutex}; + +fn main() -> anyhow::Result<()> { + let port: u16 = std::env::var("CT_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8484); + let headless = std::env::args().any(|a| a == "--headless"); + + let app = Arc::new(Mutex::new(App::new())); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + let papp = app.clone(); + let proxy_handle = rt.spawn(async move { + if let Err(e) = proxy::run(papp.clone(), port).await { + let mut a = papp.lock().unwrap(); + a.status = format!("proxy failed: {e}"); + } + }); + + if headless { + eprintln!("claude-thinking proxy on 127.0.0.1:{port} (headless, ctrl-c to quit)"); + rt.block_on(proxy_handle)?; + Ok(()) + } else { + let r = ui::run(app); + rt.shutdown_background(); + r + } +} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..ca95ec7 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,129 @@ +use crate::app::{attach_tool_results, SharedApp, Tap}; +use crate::sse::SseParser; +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::http::Method; +use axum::response::Response; +use axum::Router; +use futures_util::StreamExt; +use serde_json::Value; + +const UPSTREAM: &str = "https://api.anthropic.com"; + +#[derive(Clone)] +struct Ctx { + client: reqwest::Client, + app: SharedApp, +} + +pub async fn run(app: SharedApp, port: u16) -> anyhow::Result<()> { + let client = reqwest::Client::builder().build()?; + let ctx = Ctx { client, app: app.clone() }; + let router = Router::new().fallback(forward).with_state(ctx); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; + app.lock().unwrap().status = + format!("proxy http://127.0.0.1:{port} → api.anthropic.com"); + axum::serve(listener, router).await?; + Ok(()) +} + +async fn forward(State(ctx): State, req: Request) -> Response { + let app = ctx.app.clone(); + match forward_inner(ctx, req).await { + Ok(r) => r, + Err(e) => { + app.lock().unwrap().status = format!("upstream error: {e}"); + Response::builder() + .status(502) + .body(Body::from(format!("claude-thinking proxy error: {e}"))) + .unwrap() + } + } +} + +async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result { + let (parts, body) = req.into_parts(); + // Claude Code sends bodies whole; buffering lets us read session metadata. + let body_bytes = axum::body::to_bytes(body, 512 * 1024 * 1024).await?; + + let pq = parts + .uri + .path_and_query() + .map(|p| p.as_str().to_owned()) + .unwrap_or_else(|| "/".into()); + let url = format!("{UPSTREAM}{pq}"); + + // Identify streaming /v1/messages requests and attach a tap. + let mut tap: Option = None; + if parts.method == Method::POST && pq.starts_with("/v1/messages") { + if let Ok(v) = serde_json::from_slice::(&body_bytes) { + if v.get("stream").and_then(Value::as_bool).unwrap_or(false) { + let model = v + .get("model") + .and_then(Value::as_str) + .unwrap_or("?") + .to_string(); + let key = v + .pointer("/metadata/user_id") + .and_then(Value::as_str) + .and_then(|u| u.split("session_").nth(1)) + .unwrap_or("unknown") + .to_string(); + // Tool results ride along in the request body; surface them + // on the tool entries from the previous turn. + attach_tool_results(&ctx.app, &key, &v); + tap = Some(Tap::new(ctx.app.clone(), key, model)); + } + } + } + + let mut rb = ctx.client.request(parts.method.clone(), &url); + for (name, value) in parts.headers.iter() { + // hop-by-hop / recomputed headers; accept-encoding stripped so the + // upstream sends an uncompressed stream we can parse in transit. + if matches!( + name.as_str(), + "host" | "content-length" | "transfer-encoding" | "connection" + | "accept-encoding" | "expect" + ) { + continue; + } + rb = rb.header(name.clone(), value.clone()); + } + let resp = rb.body(body_bytes).send().await?; + + let mut builder = Response::builder().status(resp.status().as_u16()); + let is_sse = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|v| v.contains("text/event-stream")) + .unwrap_or(false); + for (name, value) in resp.headers() { + if matches!( + name.as_str(), + "content-length" | "transfer-encoding" | "connection" + ) { + continue; + } + builder = builder.header(name.clone(), value.clone()); + } + + // Stream the body back unbuffered; tee SSE bytes into the parser. + let body = match (is_sse, tap) { + (true, Some(mut tap)) => { + let mut parser = SseParser::default(); + let stream = resp.bytes_stream().map(move |chunk| { + if let Ok(b) = &chunk { + for (ev, data) in parser.feed(b) { + tap.handle(&ev, &data); + } + } + chunk + }); + Body::from_stream(stream) + } + _ => Body::from_stream(resp.bytes_stream()), + }; + Ok(builder.body(body)?) +} diff --git a/src/sse.rs b/src/sse.rs new file mode 100644 index 0000000..a437583 --- /dev/null +++ b/src/sse.rs @@ -0,0 +1,78 @@ +use serde_json::Value; + +/// Incremental SSE parser. Fed raw bytes as they pass through the proxy, +/// yields complete (event_name, parsed_data) pairs. +#[derive(Default)] +pub struct SseParser { + buf: Vec, +} + +impl SseParser { + pub fn feed(&mut self, chunk: &[u8]) -> Vec<(String, Value)> { + self.buf.extend_from_slice(chunk); + let mut out = Vec::new(); + while let Some((content_end, drain_end)) = find_event_end(&self.buf) { + let event: Vec = self.buf.drain(..drain_end).collect(); + let text = String::from_utf8_lossy(&event[..content_end]); + let mut name = String::new(); + let mut data = String::new(); + for line in text.lines() { + if let Some(v) = line.strip_prefix("event:") { + name = v.trim().to_string(); + } else if let Some(v) = line.strip_prefix("data:") { + data.push_str(v.strip_prefix(' ').unwrap_or(v)); + } + } + if !data.is_empty() { + if let Ok(v) = serde_json::from_str(&data) { + out.push((name, v)); + } + } + } + out + } +} + +/// Find the end of the next complete SSE event ("\n\n" or "\r\n\r\n"). +/// Returns (content_end, drain_end). +fn find_event_end(buf: &[u8]) -> Option<(usize, usize)> { + let mut i = 0; + while i + 1 < buf.len() { + if buf[i] == b'\n' && buf[i + 1] == b'\n' { + return Some((i, i + 2)); + } + if buf[i] == b'\r' && i + 3 < buf.len() && &buf[i..i + 4] == b"\r\n\r\n" { + return Some((i, i + 4)); + } + i += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_split_events() { + let mut p = SseParser::default(); + let ev = p.feed(b"event: content_block_delta\ndata: {\"delta\":{\"type\":\"thinking_del"); + assert!(ev.is_empty()); + let ev = p.feed(b"ta\",\"thinking\":\" hello\"}}\n\nevent: ping\ndata: {}\n\n"); + assert_eq!(ev.len(), 2); + assert_eq!(ev[0].0, "content_block_delta"); + assert_eq!( + ev[0].1.pointer("/delta/thinking").unwrap().as_str().unwrap(), + " hello" + ); + assert_eq!(ev[1].0, "ping"); + } + + #[test] + fn parses_crlf_events() { + let mut p = SseParser::default(); + let ev = p.feed(b"event: message_stop\r\ndata: {\"type\":\"message_stop\"}\r\n\r\n"); + assert_eq!(ev.len(), 1); + assert_eq!(ev[0].0, "message_stop"); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..a983982 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,507 @@ +use crate::app::{filter_index, fmt_tokens, Kind, SharedApp, ToolResult, FILTER_LABELS}; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span, Text}; +use serde_json::Value; +use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}; +use ratatui::Frame; +use std::time::Duration; + +pub fn run(app: SharedApp) -> anyhow::Result<()> { + let mut terminal = ratatui::init(); + let res = event_loop(&mut terminal, app); + ratatui::restore(); + res +} + +fn event_loop(terminal: &mut ratatui::DefaultTerminal, app: SharedApp) -> anyhow::Result<()> { + loop { + terminal.draw(|f| draw(f, &app))?; + if !event::poll(Duration::from_millis(33))? { + continue; + } + if let Event::Key(k) = event::read()? { + if k.kind != KeyEventKind::Press { + continue; + } + let mut a = app.lock().unwrap(); + let nsess = a.sessions.len(); + if k.code == KeyCode::Char('c') && k.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(()); + } + // Filter popup captures input while open. + if let Some(sel) = a.filter_popup { + let n = FILTER_LABELS.len(); + match k.code { + KeyCode::Char(' ') => a.filters[sel] = !a.filters[sel], + KeyCode::Up | KeyCode::Char('k') => { + a.filter_popup = Some((sel + n - 1) % n) + } + KeyCode::Down | KeyCode::Char('j') => a.filter_popup = Some((sel + 1) % n), + KeyCode::Char('f') | KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => { + a.filter_popup = None + } + _ => {} + } + continue; + } + match k.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('f') => a.filter_popup = Some(0), + KeyCode::Char('s') => a.show_sessions = !a.show_sessions, + KeyCode::Tab if nsess > 0 => { + a.selected = (a.selected + 1) % nsess; + a.follow = true; + } + KeyCode::BackTab if nsess > 0 => { + a.selected = (a.selected + nsess - 1) % nsess; + a.follow = true; + } + KeyCode::Up | KeyCode::Char('k') => { + a.follow = false; + a.scroll = a.scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + a.follow = false; + a.scroll += 1; + } + KeyCode::PageUp => { + a.follow = false; + a.scroll = a.scroll.saturating_sub(20); + } + KeyCode::PageDown => { + a.follow = false; + a.scroll += 20; + } + KeyCode::Home | KeyCode::Char('g') => { + a.follow = false; + a.scroll = 0; + } + KeyCode::End | KeyCode::Char('G') => a.follow = true, + _ => {} + } + } + } +} + +fn draw(f: &mut Frame, app: &SharedApp) { + let mut a = app.lock().unwrap(); + let [main, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) + .areas(f.area()); + let left_width = if a.show_sessions { 26 } else { 0 }; + let [left, right] = Layout::horizontal([Constraint::Length(left_width), Constraint::Min(10)]) + .areas(main); + + let sel = a.selected.min(a.sessions.len().saturating_sub(1)); + a.selected = sel; + + // Session list (folded away when toggled off) + if a.show_sessions { + let items: Vec = a + .sessions + .iter() + .map(|s| { + let dot = if s.active > 0 { "●".green() } else { "○".dark_gray() }; + let id: String = s.key.chars().take(8).collect(); + ListItem::new(Line::from(vec![ + dot, + " ".into(), + id.into(), + " ".into(), + short_model(&s.model).cyan(), + ])) + }) + .collect(); + let mut ls = ListState::default(); + if !a.sessions.is_empty() { + ls.select(Some(sel)); + } + f.render_stateful_widget( + List::new(items) + .block(Block::bordered().title(" sessions ")) + .highlight_style(ratatui::style::Style::new().reversed()), + left, + &mut ls, + ); + } + + // Feed + let follow = a.follow; + let scroll0 = a.scroll; + let filters = a.filters; + let mut new_scroll = scroll0; + let mut new_follow = follow; + if let Some(s) = a.sessions.get(sel) { + let feed_width = right.width.saturating_sub(2); + let mut lines: Vec = Vec::new(); + for e in &s.entries { + if !filters[filter_index(&e.kind)] { + continue; + } + match &e.kind { + Kind::Meta => lines.push(Line::from(e.content.clone()).dark_gray()), + Kind::Thinking => { + let head = if e.done { "✻ thought" } else { "✻ thinking…" }; + lines.push(Line::from(head).magenta().italic()); + for l in e.content.lines() { + lines.push(Line::from(l.to_string()).dark_gray().italic()); + } + } + Kind::Text => { + lines.extend(tui_markdown::from_str(&e.content).lines); + } + Kind::Tool { name } => { + // Once the input JSON is complete, every tool gets a + // human-readable rendering; partial streams fall back to + // the raw JSON-fragment view. + let parsed = e + .done + .then(|| serde_json::from_str::(&e.content).ok()) + .flatten(); + match parsed { + Some(v) => { + render_tool(name, &v, e.result.as_ref(), &mut lines, feed_width) + } + None => { + let head = if e.done { + format!("⚙ {name}") + } else { + format!("⚙ {name} …") + }; + lines.push(Line::from(head).yellow().bold()); + for l in e.content.lines() { + lines.push(Line::from(format!(" {l}")).cyan()); + } + } + } + } + Kind::Error => { + for l in e.content.lines() { + lines.push(Line::from(l.to_string()).red().bold()); + } + } + } + lines.push(Line::default()); + } + let title = if a.show_sessions { + format!( + " {} · in {} · out {} ", + s.model, + fmt_tokens(s.input_tokens), + fmt_tokens(s.output_tokens) + ) + } else { + let id: String = s.key.chars().take(8).collect(); + format!( + " {} · {} · in {} · out {} ", + id, + s.model, + fmt_tokens(s.input_tokens), + fmt_tokens(s.output_tokens) + ) + }; + let p = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + let width = right.width.saturating_sub(2); + let height = right.height.saturating_sub(2) as usize; + let max_scroll = p.line_count(width).saturating_sub(height); + new_scroll = if follow { max_scroll } else { scroll0.min(max_scroll) }; + // Reaching the bottom re-engages follow automatically. + if new_scroll >= max_scroll { + new_follow = true; + } + f.render_widget( + p.block(Block::bordered().title(title)) + .scroll((new_scroll.min(u16::MAX as usize) as u16, 0)), + right, + ); + } else { + f.render_widget( + Paragraph::new( + "\n\n waiting for traffic…\n\n make sure claude runs with:\n ANTHROPIC_BASE_URL=http://127.0.0.1:8484", + ) + .dark_gray() + .block(Block::bordered()), + right, + ); + } + a.scroll = new_scroll; + a.follow = new_follow; + + let keys = if a.filter_popup.is_some() { + "space toggle · j/k move · f/esc close" + } else { + "q quit · tab session · s sessions · j/k scroll · f filter · g top · G bottom" + }; + f.render_widget( + Paragraph::new(Line::from(format!(" {} | {keys}", a.status)).dark_gray()), + footer, + ); + + // Filter popup + if let Some(fsel) = a.filter_popup { + let w = 26u16.min(main.width); + let h = (FILTER_LABELS.len() as u16 + 2).min(main.height); + let area = Rect { + x: main.x + (main.width.saturating_sub(w)) / 2, + y: main.y + (main.height.saturating_sub(h)) / 2, + width: w, + height: h, + }; + f.render_widget(Clear, area); + let items: Vec = FILTER_LABELS + .iter() + .enumerate() + .map(|(i, name)| { + let mark = if a.filters[i] { "[x]" } else { "[ ]" }; + ListItem::new(format!(" {mark} {name}")) + }) + .collect(); + let mut ls = ListState::default(); + ls.select(Some(fsel)); + f.render_stateful_widget( + List::new(items) + .block(Block::bordered().title(" filter ")) + .highlight_style(ratatui::style::Style::new().reversed()), + area, + &mut ls, + ); + } +} + +fn short_model(m: &str) -> String { + m.strip_prefix("claude-").unwrap_or(m).to_string() +} + +/// Per-tool human-readable rendering, plus the tool_result (if it has come +/// back in a subsequent request) attached underneath. +fn render_tool<'a>( + name: &str, + input: &Value, + result: Option<&ToolResult>, + out: &mut Vec>, + width: u16, +) { + let sf = |k: &str| input.get(k).and_then(Value::as_str); + match name.to_ascii_lowercase().as_str() { + // Diff/content view; success confirmations are noise, only surface failures. + "write" | "edit" if render_file_tool(name, input, out, width) => { + if result.is_some_and(|r| r.is_error) { + push_result(out, result, None); + } + } + "read" => { + let mut head = vec![ + "⚙ Read ".yellow().bold(), + sf("file_path").unwrap_or("?").to_string().bold(), + ]; + // offset/limit (or pages) = an explicit range: show the whole + // result. Plain reads get clipped to 5 lines. + let ranged = input.get("offset").is_some() + || input.get("limit").is_some() + || input.get("pages").is_some(); + if ranged { + let mut parts = Vec::new(); + if let Some(o) = input.get("offset").and_then(Value::as_u64) { + parts.push(format!("offset {o}")); + } + if let Some(l) = input.get("limit").and_then(Value::as_u64) { + parts.push(format!("limit {l}")); + } + if let Some(p) = sf("pages") { + parts.push(format!("pages {p}")); + } + head.push(format!(" ({})", parts.join(", ")).dark_gray()); + } + out.push(Line::from(head)); + push_result(out, result, if ranged { None } else { Some(5) }); + } + "bash" => { + let cmd = sf("command").unwrap_or("?"); + let mut cmd_lines = cmd.lines(); + out.push(Line::from(vec![ + "⚙ Bash ".yellow().bold(), + sanitize(cmd_lines.next().unwrap_or("")).cyan(), + ])); + for l in cmd_lines { + out.push(Line::from(format!(" {}", sanitize(l))).cyan()); + } + push_result(out, result, None); + } + "glob" | "grep" => { + out.push(Line::from(vec![ + format!("⚙ {name} ").yellow().bold(), + format!("\"{}\"", sf("pattern").unwrap_or("?")).cyan(), + " in ".dark_gray(), + sf("path").unwrap_or(".").to_string().into(), + ])); + push_result(out, result, None); + } + "todowrite" => { + out.push(Line::from("⚙ Todos").yellow().bold()); + for t in input + .get("todos") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + { + let content = t.get("content").and_then(Value::as_str).unwrap_or("?"); + let row = |mark: &str| format!(" {mark} {}", sanitize(content)); + out.push(match t.get("status").and_then(Value::as_str) { + Some("completed") => Line::from(row("☑")).green(), + Some("in_progress") => Line::from(row("◐")).yellow(), + _ => Line::from(row("☐")).dark_gray(), + }); + } + // The result just echoes the list back; only surface failures. + if result.is_some_and(|r| r.is_error) { + push_result(out, result, None); + } + } + // Generic fallback: tool name header, inputs as `key: value` rows. + _ => { + out.push(Line::from(format!("⚙ {name}")).yellow().bold()); + match input.as_object() { + Some(obj) => { + for (k, v) in obj { + let val = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + out.push(Line::from(vec![ + format!(" {k}: ").dark_gray(), + one_line(&val).cyan(), + ])); + } + } + None => { + for l in input.to_string().lines() { + out.push(Line::from(format!(" {}", sanitize(l))).cyan()); + } + } + } + push_result(out, result, None); + } + } +} + +/// Renders a tool_result under its tool entry: `⎿`-marked, dimmed (red when +/// `is_error`). `limit` clips to the first N lines with a "N more lines" tail. +fn push_result<'a>(out: &mut Vec>, result: Option<&ToolResult>, limit: Option) { + let Some(r) = result else { return }; + if r.content.is_empty() { + if r.is_error { + out.push(Line::from(" ⎿ (error)").red()); + } + return; + } + let total = r.content.lines().count(); + let shown = limit.map_or(total, |n| n.min(total)); + for (i, l) in r.content.lines().take(shown).enumerate() { + let prefix = if i == 0 { " ⎿ " } else { " " }; + let line = format!("{prefix}{}", sanitize(l)); + out.push(if r.is_error { + Line::from(line).red() + } else { + Line::from(line).dark_gray() + }); + } + if shown < total { + out.push( + Line::from(format!(" {} more lines", total - shown)) + .dark_gray() + .italic(), + ); + } +} + +/// First line only, clipped, with an ellipsis when anything was dropped. +fn one_line(s: &str) -> String { + const MAX: usize = 120; + let first = s.lines().next().unwrap_or(""); + let clipped: String = first.chars().take(MAX).collect(); + if clipped.len() < s.len() { + format!("{}…", sanitize(&clipped)) + } else { + sanitize(&clipped) + } +} + +/// Dark diff backgrounds that stay readable under white/default text. +const DIFF_DEL: Color = Color::Indexed(52); // dark red +const DIFF_ADD: Color = Color::Indexed(22); // dark green + +/// Human-readable rendering for file-mutating tools: `file_path` as header, +/// body as numbered lines (Edit shows old/new as a red/green diff). +/// Returns false when the tool/input isn't one we special-case. +fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec>, width: u16) -> bool { + let Some(path) = input.get("file_path").and_then(Value::as_str) else { + return false; + }; + let str_field = |k: &str| input.get(k).and_then(Value::as_str); + match name.to_ascii_lowercase().as_str() { + "write" => { + let Some(content) = str_field("content") else { return false }; + out.push(Line::from(vec![ + "⚙ Write ".yellow().bold(), + path.to_string().bold(), + ])); + push_numbered(out, content, None, width); + true + } + "edit" => { + let (Some(old), Some(new)) = (str_field("old_string"), str_field("new_string")) + else { + return false; + }; + let mut head = vec!["⚙ Edit ".yellow().bold(), path.to_string().bold()]; + if input.get("replace_all").and_then(Value::as_bool) == Some(true) { + head.push(" (replace_all)".dark_gray()); + } + out.push(Line::from(head)); + push_numbered(out, old, Some(DIFF_DEL), width); + push_numbered(out, new, Some(DIFF_ADD), width); + true + } + _ => false, + } +} + +/// Pushes `text` line by line with a line-number gutter. With a `bg`, the +/// whole row (gutter included) is white-on-bg and padded to `width` so the +/// background forms a solid block; without one, the gutter is dark gray. +fn push_numbered<'a>(out: &mut Vec>, text: &str, bg: Option, width: u16) { + let gutter = text.lines().count().max(1).to_string().len(); + for (i, l) in text.lines().enumerate() { + let l = sanitize(l); + match bg { + Some(bg) => { + let mut row = format!("{:>gutter$} │ {l}", i + 1); + let pad = (width as usize).saturating_sub(row.chars().count()); + row.extend(std::iter::repeat(' ').take(pad)); + out.push(Line::from(Span::styled( + row, + Style::new().bg(bg).fg(Color::White), + ))); + } + None => out.push(Line::from(vec![ + format!("{:>gutter$} │ ", i + 1).dark_gray(), + Span::raw(l), + ])), + } + } +} + +/// ratatui renders control chars as zero-width, smearing the layout +/// (tab-indented code was the main offender); expand tabs, drop the rest. +fn sanitize(l: &str) -> String { + let mut s = String::with_capacity(l.len()); + for c in l.chars() { + match c { + '\t' => s.push_str(" "), + c if c.is_control() => {} + c => s.push(c), + } + } + s +}