From 5cedfd1aca313b3b1388e69b91ad1f4a44648cb9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Wed, 18 Feb 2026 16:56:59 -0500 Subject: [PATCH] initial commit --- .gitignore | 1 + AGENTS.md | 113 +++ Cargo.lock | 1721 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 49 ++ src/data.rs | 464 +++++++++++ src/main.rs | 1897 +++++++++++++++++++++++++++++++++++++++++++++ src/tui/app.rs | 443 +++++++++++ src/tui/mod.rs | 7 + src/tui/report.rs | 204 +++++ src/tui/views.rs | 595 ++++++++++++++ 10 files changed, 5494 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/data.rs create mode 100644 src/main.rs create mode 100644 src/tui/app.rs create mode 100644 src/tui/mod.rs create mode 100644 src/tui/report.rs create mode 100644 src/tui/views.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c101bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# claude-stats - Agent Interface Guide + +CLI for Claude Code usage statistics. Designed for both human and AI agent use. + +## Quick Start (Agents) + +```bash +# JSON output (auto-enabled when piped) +claude-stats --json # Summary with tokens, costs, efficiency +claude-stats daily --json # Daily breakdown +claude-stats efficiency --json # Cache hit rate, cost analysis +``` + +## Commands + +| Command | Purpose | Example | +|---------|---------|---------| +| (none) | Summary stats | `claude-stats --json` | +| `daily` | Daily breakdown | `claude-stats daily -n 7 --json` | +| `weekly` | Weekly breakdown | `claude-stats weekly -n 4 --json` | +| `sessions` | Recent sessions | `claude-stats sessions -n 10 --json` | +| `projects` | By project | `claude-stats projects --json` | +| `hourly` | By hour | `claude-stats hourly --json` | +| `models` | Model usage | `claude-stats models --json` | +| `efficiency` | Cache/cost metrics | `claude-stats efficiency --json` | +| `all` | Everything | `claude-stats all --json` | + +## Flags + +| Flag | Purpose | +|------|---------| +| `--json` | JSON output (auto when piped) | +| `-n N` | Limit/days count | +| `-q` | Quiet (no progress) | +| `-d PATH` | Custom data dir | + +## JSON Response Format + +```json +{ + "ok": true, + "data": { ... }, + "meta": { + "sessions_parsed": 2478, + "files_scanned": 2478, + "period_days": 30 + } +} +``` + +## Error Format + +```json +{ + "ok": false, + "error": { + "code": 2, + "message": "Unknown command 'daytime'", + "suggestions": [ + "claude-stats daily -n 7", + "claude-stats --help" + ] + } +} +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | No data found | +| 2 | Invalid arguments | +| 3 | Data directory not found | +| 4 | Parse error | + +## Error Tolerance + +The CLI is forgiving of minor syntax issues: + +| What You Type | Interpreted As | +|---------------|----------------| +| `daly`, `dayly` | `daily` | +| `session` | `sessions` | +| `mod`, `model` | `models` | +| `eff`, `efficency` | `efficiency` | +| `stats`, `summary` | `all` | +| `-json`, `-J` | `--json` | +| `-l`, `--limit` | `-n` | +| `-n7` | `-n 7` | +| `--days=7` | `--days 7` | + +## Example Workflows + +### Get token usage for analysis +```bash +claude-stats --json | jq '.data.tokens.total_billed' +``` + +### Check cache efficiency +```bash +claude-stats efficiency --json | jq '.data.cache_hit_rate' +``` + +### Daily usage trend +```bash +claude-stats daily -n 7 --json | jq '.data[] | {date, tokens}' +``` + +### Find highest usage project +```bash +claude-stats projects --json | jq '.data[0]' +``` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..324e092 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1721 @@ +# 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 = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[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.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +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 = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "claude-stats" +version = "0.2.0" +dependencies = [ + "anyhow", + "arboard", + "base64", + "chrono", + "clap", + "colored", + "comfy-table", + "crossterm 0.28.1", + "dirs", + "indicatif", + "num-format", + "ratatui", + "rayon", + "serde", + "serde_json", + "unicode-width 0.2.0", + "walkdir", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "crossterm 0.29.0", + "unicode-segmentation", + "unicode-width 0.2.0", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.3", + "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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[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 = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[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 = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[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 = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "rayon", + "unicode-width 0.2.0", + "web-time", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[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 = "pxfm" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[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 0.28.1", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[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 = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[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 = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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 = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8d57f92 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "claude-stats" +version = "0.2.0" +edition = "2021" +description = "Detailed usage statistics for Claude Code - TUI & CLI" + +[dependencies] +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# CLI parsing +clap = { version = "4.4", features = ["derive"] } + +# File system +walkdir = "2.4" +dirs = "5.0" + +# Parallel processing +rayon = "1.8" + +# Progress indication (CLI mode) +indicatif = { version = "0.17", features = ["rayon"] } + +# CLI output +comfy-table = "7.1" +colored = "2.1" +num-format = "0.4" + +# Error handling +anyhow = "1.0" + +# TUI framework +ratatui = "0.29" +crossterm = "0.28" + +# Clipboard +arboard = "3.4" +base64 = "0.22" + +# Unicode width for proper text rendering +unicode-width = "0.2" + +[profile.release] +opt-level = 3 +lto = true diff --git a/src/data.rs b/src/data.rs new file mode 100644 index 0000000..c8bc2e6 --- /dev/null +++ b/src/data.rs @@ -0,0 +1,464 @@ +// Data structures and parsing logic for Claude Code statistics + +use anyhow::{Context, Result}; +use chrono::{DateTime, Datelike, Duration, IsoWeek, Local, NaiveDate, Timelike, Utc}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use walkdir::WalkDir; + +// ═══════════════════════════════════════════════════════════════════════════════ +// DATA STRUCTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Deserialize)] +pub struct SessionEntry { + #[serde(rename = "type")] + pub entry_type: Option, + pub timestamp: Option, + #[serde(rename = "sessionId")] + pub session_id: Option, + pub message: Option, + pub cwd: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Message { + pub role: Option, + pub model: Option, + pub usage: Option, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Usage { + pub input_tokens: Option, + pub output_tokens: Option, + pub cache_creation_input_tokens: Option, + pub cache_read_input_tokens: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct SessionStats { + pub session_id: String, + pub project: String, + pub start_time: Option>, + pub end_time: Option>, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, + pub user_messages: u64, + pub assistant_messages: u64, + pub models: HashMap, +} + +impl SessionStats { + pub fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + self.cache_creation_tokens + } + + pub fn duration_minutes(&self) -> Option { + match (self.start_time, self.end_time) { + (Some(start), Some(end)) => Some((end - start).num_minutes()), + _ => None, + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct DailyStats { + pub date: NaiveDate, + pub sessions: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, + pub user_messages: u64, + pub assistant_messages: u64, + pub total_minutes: i64, +} + +impl DailyStats { + pub fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + self.cache_creation_tokens + } +} + +#[derive(Debug, Clone)] +pub struct WeeklyStats { + pub sessions: u64, + pub total_tokens: u64, + pub user_messages: u64, + pub total_minutes: i64, +} + +#[derive(Debug, Default, Clone)] +pub struct ProjectStats { + pub name: String, + pub sessions: u64, + pub total_tokens: u64, + pub total_messages: u64, +} + +#[derive(Debug, Default, Clone)] +pub struct ModelStats { + pub name: String, + pub requests: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SummaryStats { + pub total_sessions: u64, + pub total_prompts: u64, + pub total_responses: u64, + pub total_minutes: i64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_creation_tokens: u64, + pub cache_read_tokens: u64, + pub total_tokens: u64, + pub unique_days: usize, + pub sessions_per_day: f64, + pub prompts_per_day: f64, + pub tokens_per_day: u64, + pub minutes_per_day: i64, + pub tokens_per_prompt: u64, + pub output_per_prompt: u64, + pub cache_hit_rate: f64, + pub responses_per_prompt: f64, + pub input_cost: f64, + pub output_cost: f64, + pub cache_write_cost: f64, + pub cache_read_cost: f64, + pub total_cost: f64, + pub cache_savings: f64, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// DATA LOADING +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn get_claude_dir(custom_path: Option) -> Result { + if let Some(path) = custom_path { + return Ok(path); + } + dirs::home_dir() + .map(|h| h.join(".claude")) + .context("Could not find home directory") +} + +pub fn find_session_files(claude_dir: &PathBuf) -> Vec { + let projects_dir = claude_dir.join("projects"); + WalkDir::new(&projects_dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext == "jsonl") + }) + .map(|e| e.path().to_path_buf()) + .collect() +} + +pub fn parse_session_file(path: &PathBuf) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let mut stats = SessionStats::default(); + + if let Some(stem) = path.file_stem() { + stats.session_id = stem.to_string_lossy().to_string(); + } + + if let Some(parent) = path.parent() { + if let Some(name) = parent.file_name() { + let project_name = name.to_string_lossy().to_string(); + stats.project = project_name.replace('-', "/"); + if stats.project.starts_with('/') { + stats.project = stats.project[1..].to_string(); + } + } + } + + let mut timestamps: Vec> = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + let entry: SessionEntry = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + if let Some(ts_str) = &entry.timestamp { + if let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) { + timestamps.push(ts.with_timezone(&Utc)); + } + } + + match entry.entry_type.as_deref() { + Some("user") => { + if entry.message.as_ref().is_some_and(|m| m.role.as_deref() == Some("user")) { + stats.user_messages += 1; + } + } + Some("assistant") => { + stats.assistant_messages += 1; + if let Some(msg) = &entry.message { + if let Some(model) = &msg.model { + *stats.models.entry(model.clone()).or_insert(0) += 1; + } + if let Some(usage) = &msg.usage { + stats.input_tokens += usage.input_tokens.unwrap_or(0); + stats.output_tokens += usage.output_tokens.unwrap_or(0); + stats.cache_creation_tokens += + usage.cache_creation_input_tokens.unwrap_or(0); + stats.cache_read_tokens += usage.cache_read_input_tokens.unwrap_or(0); + } + } + } + _ => {} + } + } + + if !timestamps.is_empty() { + stats.start_time = timestamps.iter().min().copied(); + stats.end_time = timestamps.iter().max().copied(); + } + + Ok(stats) +} + +pub fn load_all_sessions(claude_dir: &PathBuf) -> Result<(Vec, usize)> { + let session_files = find_session_files(claude_dir); + let file_count = session_files.len(); + + let sessions: Vec = session_files + .par_iter() + .filter_map(|path| parse_session_file(path).ok()) + .collect(); + + Ok((sessions, file_count)) +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// AGGREGATION FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn compute_summary(sessions: &[SessionStats], days: u32) -> SummaryStats { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let recent: Vec<_> = sessions + .iter() + .filter(|s| s.start_time.is_some_and(|t| t >= cutoff)) + .collect(); + + let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum(); + let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum(); + let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum(); + let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum(); + let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum(); + let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum(); + let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum(); + let total_tokens = total_input + total_output + total_cache_creation; + + let unique_days = recent + .iter() + .filter_map(|s| s.start_time) + .map(|t| t.with_timezone(&Local).date_naive()) + .collect::>() + .len() + .max(1); + + let cache_hit_rate = if total_cache_creation + total_cache_read > 0 { + (total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0 + } else { + 0.0 + }; + + let input_cost = total_input as f64 * 0.000015; + let output_cost = total_output as f64 * 0.000075; + let cache_write_cost = total_cache_creation as f64 * 0.00001875; + let cache_read_cost = total_cache_read as f64 * 0.0000015; + let cache_savings = total_cache_read as f64 * (0.000015 - 0.0000015); + + SummaryStats { + total_sessions: recent.len() as u64, + total_prompts: total_user_msgs, + total_responses: total_assistant_msgs, + total_minutes, + input_tokens: total_input, + output_tokens: total_output, + cache_creation_tokens: total_cache_creation, + cache_read_tokens: total_cache_read, + total_tokens, + unique_days, + sessions_per_day: recent.len() as f64 / unique_days as f64, + prompts_per_day: total_user_msgs as f64 / unique_days as f64, + tokens_per_day: total_tokens / unique_days as u64, + minutes_per_day: total_minutes / unique_days as i64, + tokens_per_prompt: if total_user_msgs > 0 { total_tokens / total_user_msgs } else { 0 }, + output_per_prompt: if total_user_msgs > 0 { total_output / total_user_msgs } else { 0 }, + cache_hit_rate, + responses_per_prompt: if total_user_msgs > 0 { total_assistant_msgs as f64 / total_user_msgs as f64 } else { 0.0 }, + input_cost, + output_cost, + cache_write_cost, + cache_read_cost, + total_cost: input_cost + output_cost + cache_write_cost + cache_read_cost, + cache_savings, + } +} + +pub fn aggregate_daily(sessions: &[SessionStats], days: u32) -> BTreeMap { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let mut daily: BTreeMap = BTreeMap::new(); + + for session in sessions { + if let Some(start) = session.start_time { + if start < cutoff { + continue; + } + let date = start.with_timezone(&Local).date_naive(); + let entry = daily.entry(date).or_insert_with(|| DailyStats { + date, + ..Default::default() + }); + + entry.sessions += 1; + entry.input_tokens += session.input_tokens; + entry.output_tokens += session.output_tokens; + entry.cache_creation_tokens += session.cache_creation_tokens; + entry.cache_read_tokens += session.cache_read_tokens; + entry.user_messages += session.user_messages; + entry.assistant_messages += session.assistant_messages; + if let Some(mins) = session.duration_minutes() { + entry.total_minutes += mins; + } + } + } + + daily +} + +pub fn aggregate_weekly(sessions: &[SessionStats], weeks: u32) -> BTreeMap { + let cutoff = Local::now().with_timezone(&Utc) - Duration::weeks(weeks as i64); + let mut weekly: BTreeMap = BTreeMap::new(); + + for session in sessions { + if let Some(start) = session.start_time { + if start < cutoff { + continue; + } + let week = start.with_timezone(&Local).date_naive().iso_week(); + let entry = weekly.entry(week).or_insert_with(|| WeeklyStats { + sessions: 0, + total_tokens: 0, + user_messages: 0, + total_minutes: 0, + }); + + entry.sessions += 1; + entry.total_tokens += session.total_tokens(); + entry.user_messages += session.user_messages; + if let Some(mins) = session.duration_minutes() { + entry.total_minutes += mins; + } + } + } + + weekly +} + +pub fn aggregate_hourly(sessions: &[SessionStats], days: u32) -> [u64; 24] { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let mut hourly = [0u64; 24]; + + for session in sessions { + if let Some(start) = session.start_time { + if start >= cutoff { + let local: DateTime = start.into(); + let hour = local.hour() as usize; + hourly[hour] += session.user_messages; + } + } + } + + hourly +} + +pub fn aggregate_projects(sessions: &[SessionStats]) -> Vec { + let mut projects: HashMap = HashMap::new(); + + for session in sessions { + let entry = projects + .entry(session.project.clone()) + .or_insert_with(|| ProjectStats { + name: session.project.clone(), + ..Default::default() + }); + + entry.sessions += 1; + entry.total_tokens += session.total_tokens(); + entry.total_messages += session.user_messages + session.assistant_messages; + } + + let mut result: Vec<_> = projects.into_values().collect(); + result.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + result +} + +pub fn aggregate_models(sessions: &[SessionStats], days: u32) -> Vec { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let mut models: HashMap = HashMap::new(); + + for session in sessions { + if session.start_time.is_some_and(|t| t >= cutoff) { + for (model, count) in &session.models { + *models.entry(model.clone()).or_insert(0) += count; + } + } + } + + let mut result: Vec<_> = models + .into_iter() + .map(|(name, requests)| ModelStats { name, requests }) + .collect(); + result.sort_by(|a, b| b.requests.cmp(&a.requests)); + result +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FORMATTING HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn format_tokens(tokens: u64) -> String { + if tokens >= 1_000_000_000 { + format!("{:.2}B", tokens as f64 / 1_000_000_000.0) + } else if tokens >= 1_000_000 { + format!("{:.2}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}K", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +pub fn format_duration(minutes: i64) -> String { + if minutes >= 60 { + format!("{}h {}m", minutes / 60, minutes % 60) + } else { + format!("{}m", minutes) + } +} + +pub fn format_number(n: u64) -> String { + use num_format::{Locale, ToFormattedString}; + n.to_formatted_string(&Locale::en) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..039935b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1897 @@ +mod data; +mod tui; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Datelike, Duration, IsoWeek, Local, NaiveDate, Timelike, Utc}; +use clap::{Parser, Subcommand}; +use colored::Colorize; +use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table}; +use indicatif::{ParallelProgressIterator, ProgressBar, ProgressStyle}; +use num_format::{Locale, ToFormattedString}; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::env; +use std::fs::File; +use std::io::{BufRead, BufReader, IsTerminal}; +use std::path::PathBuf; +use std::process::ExitCode; +use walkdir::WalkDir; + +// ═══════════════════════════════════════════════════════════════════════════════ +// EXIT CODES +// ═══════════════════════════════════════════════════════════════════════════════ +const EXIT_SUCCESS: u8 = 0; +const EXIT_NO_DATA: u8 = 1; +const EXIT_INVALID_ARGS: u8 = 2; +const EXIT_DATA_DIR_NOT_FOUND: u8 = 3; +const EXIT_PARSE_ERROR: u8 = 4; + +// ═══════════════════════════════════════════════════════════════════════════════ +// JSON OUTPUT STRUCTURES +// ═══════════════════════════════════════════════════════════════════════════════ + +#[derive(Serialize)] +struct JsonOutput { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + meta: Option, +} + +#[derive(Serialize)] +struct JsonError { + code: u8, + message: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + suggestions: Vec, +} + +#[derive(Serialize)] +struct JsonMeta { + sessions_parsed: usize, + files_scanned: usize, + period_days: u32, +} + +#[derive(Serialize)] +struct SummaryJson { + sessions: u64, + prompts: u64, + responses: u64, + total_minutes: i64, + tokens: TokensJson, + averages: AveragesJson, + efficiency: EfficiencyJson, + cost_estimate: CostJson, +} + +#[derive(Serialize)] +struct TokensJson { + input: u64, + output: u64, + cache_creation: u64, + cache_read: u64, + total_billed: u64, +} + +#[derive(Serialize)] +struct AveragesJson { + sessions_per_day: f64, + prompts_per_day: f64, + tokens_per_day: u64, + minutes_per_day: i64, +} + +#[derive(Serialize)] +struct EfficiencyJson { + tokens_per_prompt: u64, + output_per_prompt: u64, + cache_hit_rate: f64, + responses_per_prompt: f64, +} + +#[derive(Serialize)] +struct CostJson { + input: f64, + output: f64, + cache_write: f64, + cache_read: f64, + total: f64, +} + +#[derive(Serialize)] +struct DailyJson { + date: String, + sessions: u64, + prompts: u64, + tokens: u64, + minutes: i64, +} + +#[derive(Serialize)] +struct WeeklyJson { + week: String, + sessions: u64, + prompts: u64, + tokens: u64, + minutes: i64, +} + +#[derive(Serialize)] +struct SessionJson { + id: String, + project: String, + start: Option, + duration_minutes: Option, + prompts: u64, + tokens: u64, +} + +#[derive(Serialize)] +struct ProjectJson { + name: String, + sessions: u64, + messages: u64, + tokens: u64, +} + +#[derive(Serialize)] +struct ModelJson { + name: String, + requests: u64, + percent: f64, +} + +#[derive(Serialize)] +struct HourlyJson { + hour: u8, + messages: u64, +} + +#[derive(Serialize)] +struct EfficiencyDetailJson { + tokens_per_prompt: MetricJson, + output_per_prompt: MetricJson, + cache_hit_rate: MetricJson, + responses_per_prompt: MetricJson, + prompts_per_session: MetricJson, + seconds_per_prompt: MetricJson, + cache_savings_usd: f64, +} + +#[derive(Serialize)] +struct MetricJson { + value: f64, + interpretation: String, +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// CLI DEFINITION +// ═══════════════════════════════════════════════════════════════════════════════ + +#[derive(Parser)] +#[command(name = "claude-stats")] +#[command(about = "Claude Code usage statistics", long_about = None)] +#[command(after_help = "EXAMPLES: + claude-stats Summary for last 30 days + claude-stats tui Interactive dashboard + claude-stats daily -n 7 Daily breakdown, 7 days + claude-stats --json JSON output for agents + claude-stats | jq . Auto-JSON when piped")] +struct Cli { + #[command(subcommand)] + command: Option, + + /// JSON output (auto-enabled when piped) + #[arg(long, global = true)] + json: bool, + + /// Path to Claude data directory + #[arg(short, long, global = true)] + data_dir: Option, + + /// Days to analyze + #[arg(short = 'n', long, default_value = "30", global = true)] + days: u32, + + /// Suppress progress output + #[arg(short, long, global = true)] + quiet: bool, +} + +#[derive(Subcommand)] +enum Commands { + /// Interactive TUI dashboard + Tui, + /// Daily breakdown + Daily { + #[arg(short = 'n', long, default_value = "14")] + days: u32, + }, + /// Weekly breakdown + Weekly { + #[arg(short = 'n', long, default_value = "8")] + weeks: u32, + }, + /// Recent sessions + Sessions { + #[arg(short = 'n', long, default_value = "20")] + count: usize, + }, + /// Project breakdown + Projects, + /// Hourly activity + Hourly, + /// Model usage + Models, + /// Efficiency metrics + Efficiency, + /// All statistics + All, +} + +#[derive(Debug, Deserialize)] +struct SessionEntry { + #[serde(rename = "type")] + entry_type: Option, + timestamp: Option, + #[serde(rename = "sessionId")] + session_id: Option, + message: Option, + cwd: Option, +} + +#[derive(Debug, Deserialize)] +struct Message { + role: Option, + model: Option, + usage: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct Usage { + input_tokens: Option, + output_tokens: Option, + cache_creation_input_tokens: Option, + cache_read_input_tokens: Option, +} + +#[derive(Debug, Default, Clone)] +struct SessionStats { + session_id: String, + project: String, + start_time: Option>, + end_time: Option>, + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + user_messages: u64, + assistant_messages: u64, + models: HashMap, +} + +impl SessionStats { + fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + self.cache_creation_tokens + } + + fn duration_minutes(&self) -> Option { + match (self.start_time, self.end_time) { + (Some(start), Some(end)) => Some((end - start).num_minutes()), + _ => None, + } + } +} + +#[derive(Debug, Default)] +struct DailyStats { + date: NaiveDate, + sessions: u64, + input_tokens: u64, + output_tokens: u64, + cache_creation_tokens: u64, + cache_read_tokens: u64, + user_messages: u64, + assistant_messages: u64, + total_minutes: i64, +} + +impl DailyStats { + fn total_tokens(&self) -> u64 { + self.input_tokens + self.output_tokens + self.cache_creation_tokens + } +} + +#[derive(Debug)] +struct WeeklyStats { + sessions: u64, + total_tokens: u64, + user_messages: u64, + total_minutes: i64, +} + +#[derive(Debug, Default)] +struct ProjectStats { + name: String, + sessions: u64, + total_tokens: u64, + total_messages: u64, +} + +#[derive(Debug, Default)] +struct ModelStats { + name: String, + requests: u64, +} + +fn get_claude_dir(custom_path: Option) -> Result { + if let Some(path) = custom_path { + return Ok(path); + } + dirs::home_dir() + .map(|h| h.join(".claude")) + .context("Could not find home directory") +} + +fn find_session_files(claude_dir: &PathBuf) -> Vec { + let projects_dir = claude_dir.join("projects"); + WalkDir::new(&projects_dir) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + e.path() + .extension() + .is_some_and(|ext| ext == "jsonl") + }) + .map(|e| e.path().to_path_buf()) + .collect() +} + +fn parse_session_file(path: &PathBuf) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let mut stats = SessionStats::default(); + + // Extract session ID from filename + if let Some(stem) = path.file_stem() { + stats.session_id = stem.to_string_lossy().to_string(); + } + + // Extract project from parent directory + if let Some(parent) = path.parent() { + if let Some(name) = parent.file_name() { + let project_name = name.to_string_lossy().to_string(); + // Convert -data-projects-foo to /data/projects/foo + stats.project = project_name.replace('-', "/"); + if stats.project.starts_with('/') { + stats.project = stats.project[1..].to_string(); + } + } + } + + let mut timestamps: Vec> = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + + let entry: SessionEntry = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + // Parse timestamp + if let Some(ts_str) = &entry.timestamp { + if let Ok(ts) = DateTime::parse_from_rfc3339(ts_str) { + timestamps.push(ts.with_timezone(&Utc)); + } + } + + // Count messages and tokens + match entry.entry_type.as_deref() { + Some("user") => { + if entry.message.as_ref().is_some_and(|m| m.role.as_deref() == Some("user")) { + stats.user_messages += 1; + } + } + Some("assistant") => { + stats.assistant_messages += 1; + if let Some(msg) = &entry.message { + // Track model usage + if let Some(model) = &msg.model { + *stats.models.entry(model.clone()).or_insert(0) += 1; + } + if let Some(usage) = &msg.usage { + stats.input_tokens += usage.input_tokens.unwrap_or(0); + stats.output_tokens += usage.output_tokens.unwrap_or(0); + stats.cache_creation_tokens += + usage.cache_creation_input_tokens.unwrap_or(0); + stats.cache_read_tokens += usage.cache_read_input_tokens.unwrap_or(0); + } + } + } + _ => {} + } + } + + if !timestamps.is_empty() { + stats.start_time = timestamps.iter().min().copied(); + stats.end_time = timestamps.iter().max().copied(); + } + + Ok(stats) +} + +fn aggregate_daily(sessions: &[SessionStats], days: u32) -> BTreeMap { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let mut daily: BTreeMap = BTreeMap::new(); + + for session in sessions { + if let Some(start) = session.start_time { + if start < cutoff { + continue; + } + let date = start.with_timezone(&Local).date_naive(); + let entry = daily.entry(date).or_insert_with(|| DailyStats { + date, + ..Default::default() + }); + + entry.sessions += 1; + entry.input_tokens += session.input_tokens; + entry.output_tokens += session.output_tokens; + entry.cache_creation_tokens += session.cache_creation_tokens; + entry.cache_read_tokens += session.cache_read_tokens; + entry.user_messages += session.user_messages; + entry.assistant_messages += session.assistant_messages; + if let Some(mins) = session.duration_minutes() { + entry.total_minutes += mins; + } + } + } + + daily +} + +fn aggregate_weekly(sessions: &[SessionStats], weeks: u32) -> BTreeMap { + let cutoff = Local::now().with_timezone(&Utc) - Duration::weeks(weeks as i64); + let mut weekly: BTreeMap = BTreeMap::new(); + + for session in sessions { + if let Some(start) = session.start_time { + if start < cutoff { + continue; + } + let week = start.with_timezone(&Local).date_naive().iso_week(); + let entry = weekly.entry(week).or_insert_with(|| WeeklyStats { + sessions: 0, + total_tokens: 0, + user_messages: 0, + total_minutes: 0, + }); + + entry.sessions += 1; + entry.total_tokens += session.total_tokens(); + entry.user_messages += session.user_messages; + if let Some(mins) = session.duration_minutes() { + entry.total_minutes += mins; + } + } + } + + weekly +} + +fn aggregate_hourly(sessions: &[SessionStats], days: u32) -> [u64; 24] { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let mut hourly = [0u64; 24]; + + for session in sessions { + if let Some(start) = session.start_time { + if start >= cutoff { + let local: DateTime = start.into(); + let hour = local.hour() as usize; + hourly[hour] += session.user_messages; + } + } + } + + hourly +} + +fn aggregate_projects(sessions: &[SessionStats]) -> Vec { + let mut projects: HashMap = HashMap::new(); + + for session in sessions { + let entry = projects + .entry(session.project.clone()) + .or_insert_with(|| ProjectStats { + name: session.project.clone(), + ..Default::default() + }); + + entry.sessions += 1; + entry.total_tokens += session.total_tokens(); + entry.total_messages += session.user_messages + session.assistant_messages; + } + + let mut result: Vec<_> = projects.into_values().collect(); + result.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens)); + result +} + +fn aggregate_models(sessions: &[SessionStats], days: u32) -> Vec { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let mut models: HashMap = HashMap::new(); + + for session in sessions { + if session.start_time.is_some_and(|t| t >= cutoff) { + for (model, count) in &session.models { + *models.entry(model.clone()).or_insert(0) += count; + } + } + } + + let mut result: Vec<_> = models + .into_iter() + .map(|(name, requests)| ModelStats { name, requests }) + .collect(); + result.sort_by(|a, b| b.requests.cmp(&a.requests)); + result +} + +fn format_tokens(tokens: u64) -> String { + if tokens >= 1_000_000_000 { + format!("{:.2}B", tokens as f64 / 1_000_000_000.0) + } else if tokens >= 1_000_000 { + format!("{:.2}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.1}K", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +fn format_duration(minutes: i64) -> String { + if minutes >= 60 { + format!("{}h {}m", minutes / 60, minutes % 60) + } else { + format!("{}m", minutes) + } +} + +fn print_summary(sessions: &[SessionStats], days: u32) { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let recent: Vec<_> = sessions + .iter() + .filter(|s| s.start_time.is_some_and(|t| t >= cutoff)) + .collect(); + + let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum(); + let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum(); + let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum(); + let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum(); + let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum(); + let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum(); + let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum(); + + let total_tokens = total_input + total_output + total_cache_creation; + + println!("\n{}", "═".repeat(60).cyan()); + println!( + "{}", + format!(" CLAUDE CODE USAGE STATISTICS ({} days)", days) + .bold() + .cyan() + ); + println!("{}\n", "═".repeat(60).cyan()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Metric").fg(Color::Cyan), + Cell::new("Value").fg(Color::Cyan), + ]); + + table.add_row(vec![ + Cell::new("Total Sessions"), + Cell::new(recent.len().to_formatted_string(&Locale::en)).fg(Color::Green), + ]); + table.add_row(vec![ + Cell::new("Total Prompts (User Messages)"), + Cell::new(total_user_msgs.to_formatted_string(&Locale::en)).fg(Color::Green), + ]); + table.add_row(vec![ + Cell::new("Total Responses"), + Cell::new(total_assistant_msgs.to_formatted_string(&Locale::en)).fg(Color::Green), + ]); + table.add_row(vec![ + Cell::new("Total Session Time"), + Cell::new(format_duration(total_minutes)).fg(Color::Yellow), + ]); + table.add_row(vec![Cell::new(""), Cell::new("")]); + table.add_row(vec![ + Cell::new("Input Tokens").fg(Color::White), + Cell::new(total_input.to_formatted_string(&Locale::en)).fg(Color::Magenta), + ]); + table.add_row(vec![ + Cell::new("Output Tokens").fg(Color::White), + Cell::new(total_output.to_formatted_string(&Locale::en)).fg(Color::Magenta), + ]); + table.add_row(vec![ + Cell::new("Cache Creation Tokens").fg(Color::White), + Cell::new(total_cache_creation.to_formatted_string(&Locale::en)).fg(Color::Magenta), + ]); + table.add_row(vec![ + Cell::new("Cache Read Tokens (saved)").fg(Color::White), + Cell::new(total_cache_read.to_formatted_string(&Locale::en)).fg(Color::Blue), + ]); + table.add_row(vec![Cell::new(""), Cell::new("")]); + table.add_row(vec![ + Cell::new("TOTAL TOKENS (billed)").fg(Color::Yellow), + Cell::new(total_tokens.to_formatted_string(&Locale::en)) + .fg(Color::Yellow), + ]); + + println!("{table}\n"); + + // Averages + if !recent.is_empty() { + let unique_days = recent + .iter() + .filter_map(|s| s.start_time) + .map(|t| t.with_timezone(&Local).date_naive()) + .collect::>() + .len(); + + if unique_days > 0 { + println!("{}", " DAILY AVERAGES".bold().yellow()); + println!("{}", "─".repeat(40).yellow()); + println!( + " Sessions/day: {}", + format!("{:.1}", recent.len() as f64 / unique_days as f64).green() + ); + println!( + " Prompts/day: {}", + format!("{:.1}", total_user_msgs as f64 / unique_days as f64).green() + ); + println!( + " Tokens/day: {}", + format_tokens(total_tokens / unique_days as u64).magenta() + ); + println!( + " Time/day: {}", + format_duration(total_minutes / unique_days as i64).yellow() + ); + println!(); + } + } + + // Efficiency metrics + if total_user_msgs > 0 { + let tokens_per_prompt = total_tokens / total_user_msgs; + let output_per_prompt = total_output / total_user_msgs; + let cache_hit_rate = if total_cache_creation + total_cache_read > 0 { + (total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0 + } else { + 0.0 + }; + + println!("{}", " EFFICIENCY METRICS".bold().green()); + println!("{}", "─".repeat(40).green()); + println!( + " Tokens/prompt: {}", + format_tokens(tokens_per_prompt).magenta() + ); + println!( + " Output/prompt: {}", + format_tokens(output_per_prompt).magenta() + ); + println!( + " Cache hit rate: {:.1}%", + cache_hit_rate + ); + println!(); + } + + // Estimated costs (rough approximation based on public API pricing for Opus 4.5) + let input_cost = total_input as f64 * 0.000015; // $15 per 1M input + let output_cost = total_output as f64 * 0.000075; // $75 per 1M output + let cache_write_cost = total_cache_creation as f64 * 0.00001875; // $18.75 per 1M + let cache_read_cost = total_cache_read as f64 * 0.0000015; // $1.50 per 1M (90% discount) + let estimated_api_cost = input_cost + output_cost + cache_write_cost + cache_read_cost; + + println!("{}", " ESTIMATED API EQUIVALENT COST (Opus 4.5)".bold().red()); + println!("{}", "─".repeat(45).red()); + println!( + " Input tokens: ${:.2}", + input_cost + ); + println!( + " Output tokens: ${:.2}", + output_cost + ); + println!( + " Cache write: ${:.2}", + cache_write_cost + ); + println!( + " Cache read (saved): ${:.2}", + cache_read_cost + ); + println!( + " {}", + format!("TOTAL: ${:.2}", estimated_api_cost).bold() + ); + println!(" Note: Max plan ($200/mo) = unlimited usage\n"); +} + +fn print_daily(daily: &BTreeMap) { + println!("\n{}", " DAILY BREAKDOWN".bold().cyan()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Date").fg(Color::Cyan), + Cell::new("Day").fg(Color::Cyan), + Cell::new("Sessions").fg(Color::Cyan), + Cell::new("Prompts").fg(Color::Cyan), + Cell::new("Tokens").fg(Color::Cyan), + Cell::new("Time").fg(Color::Cyan), + ]); + + for (date, stats) in daily.iter().rev() { + let day_name = date.weekday().to_string(); + table.add_row(vec![ + Cell::new(date.format("%Y-%m-%d").to_string()), + Cell::new(&day_name[..3]), + Cell::new(stats.sessions), + Cell::new(stats.user_messages), + Cell::new(format_tokens(stats.total_tokens())), + Cell::new(format_duration(stats.total_minutes)), + ]); + } + + println!("{table}\n"); +} + +fn print_weekly(weekly: &BTreeMap) { + println!("\n{}", " WEEKLY BREAKDOWN".bold().cyan()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Week").fg(Color::Cyan), + Cell::new("Sessions").fg(Color::Cyan), + Cell::new("Prompts").fg(Color::Cyan), + Cell::new("Tokens").fg(Color::Cyan), + Cell::new("Time").fg(Color::Cyan), + ]); + + for (week, stats) in weekly.iter().rev() { + table.add_row(vec![ + Cell::new(format!("{}-W{:02}", week.year(), week.week())), + Cell::new(stats.sessions), + Cell::new(stats.user_messages), + Cell::new(format_tokens(stats.total_tokens)), + Cell::new(format_duration(stats.total_minutes)), + ]); + } + + println!("{table}\n"); +} + +fn print_sessions(sessions: &[SessionStats], count: usize) { + println!("\n{}", " RECENT SESSIONS".bold().cyan()); + + let mut recent: Vec<_> = sessions.iter().collect(); + recent.sort_by(|a, b| b.start_time.cmp(&a.start_time)); + recent.truncate(count); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Start Time").fg(Color::Cyan), + Cell::new("Duration").fg(Color::Cyan), + Cell::new("Prompts").fg(Color::Cyan), + Cell::new("Tokens").fg(Color::Cyan), + Cell::new("Project").fg(Color::Cyan), + ]); + + for session in recent { + let start = session + .start_time + .map(|t| { + let local: DateTime = t.into(); + local.format("%m-%d %H:%M").to_string() + }) + .unwrap_or_else(|| "Unknown".to_string()); + + let duration = session + .duration_minutes() + .map(format_duration) + .unwrap_or_else(|| "N/A".to_string()); + + let project = if session.project.len() > 30 { + format!("...{}", &session.project[session.project.len() - 27..]) + } else { + session.project.clone() + }; + + table.add_row(vec![ + Cell::new(start), + Cell::new(duration), + Cell::new(session.user_messages), + Cell::new(format_tokens(session.total_tokens())), + Cell::new(project), + ]); + } + + println!("{table}\n"); +} + +fn print_projects(projects: &[ProjectStats], limit: usize) { + println!("\n{}", " TOP PROJECTS BY TOKEN USAGE".bold().cyan()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Project").fg(Color::Cyan), + Cell::new("Sessions").fg(Color::Cyan), + Cell::new("Messages").fg(Color::Cyan), + Cell::new("Total Tokens").fg(Color::Cyan), + ]); + + for project in projects.iter().take(limit) { + let name = if project.name.len() > 40 { + format!("...{}", &project.name[project.name.len() - 37..]) + } else { + project.name.clone() + }; + + table.add_row(vec![ + Cell::new(name), + Cell::new(project.sessions), + Cell::new(project.total_messages), + Cell::new(format_tokens(project.total_tokens)), + ]); + } + + println!("{table}\n"); +} + +fn print_models(models: &[ModelStats]) { + println!("\n{}", " MODEL USAGE".bold().cyan()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Model").fg(Color::Cyan), + Cell::new("Requests").fg(Color::Cyan), + Cell::new("% of Total").fg(Color::Cyan), + ]); + + let total: u64 = models.iter().map(|m| m.requests).sum(); + + for model in models.iter().take(10) { + let pct = if total > 0 { + (model.requests as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + + // Shorten model name for display + let short_name = model.name + .replace("claude-", "") + .replace("-20251101", "") + .replace("-20250514", ""); + + table.add_row(vec![ + Cell::new(short_name), + Cell::new(model.requests.to_formatted_string(&Locale::en)), + Cell::new(format!("{:.1}%", pct)), + ]); + } + + println!("{table}\n"); +} + +fn print_efficiency(sessions: &[SessionStats], days: u32) { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let recent: Vec<_> = sessions + .iter() + .filter(|s| s.start_time.is_some_and(|t| t >= cutoff)) + .collect(); + + let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum(); + let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum(); + let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum(); + let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum(); + let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum(); + let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum(); + let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum(); + let total_tokens = total_input + total_output + total_cache_creation; + + println!("\n{}", "═".repeat(60).cyan()); + println!( + "{}", + format!(" EFFICIENCY ANALYSIS ({} days)", days) + .bold() + .cyan() + ); + println!("{}\n", "═".repeat(60).cyan()); + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_header(vec![ + Cell::new("Metric").fg(Color::Cyan), + Cell::new("Value").fg(Color::Cyan), + Cell::new("Interpretation").fg(Color::Cyan), + ]); + + // Tokens per prompt + if total_user_msgs > 0 { + let tokens_per_prompt = total_tokens / total_user_msgs; + let interpretation = if tokens_per_prompt > 50000 { + "Heavy context usage" + } else if tokens_per_prompt > 20000 { + "Moderate context" + } else { + "Light usage" + }; + table.add_row(vec![ + Cell::new("Tokens per Prompt"), + Cell::new(format_tokens(tokens_per_prompt)), + Cell::new(interpretation), + ]); + } + + // Output per prompt + if total_user_msgs > 0 { + let output_per_prompt = total_output / total_user_msgs; + let interpretation = if output_per_prompt > 1000 { + "Detailed responses" + } else if output_per_prompt > 300 { + "Moderate responses" + } else { + "Brief responses" + }; + table.add_row(vec![ + Cell::new("Output per Prompt"), + Cell::new(format_tokens(output_per_prompt)), + Cell::new(interpretation), + ]); + } + + // Cache efficiency + let cache_hit_rate = if total_cache_creation + total_cache_read > 0 { + (total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0 + } else { + 0.0 + }; + let cache_interpretation = if cache_hit_rate > 90.0 { + "Excellent caching" + } else if cache_hit_rate > 70.0 { + "Good caching" + } else { + "Low cache reuse" + }; + table.add_row(vec![ + Cell::new("Cache Hit Rate"), + Cell::new(format!("{:.1}%", cache_hit_rate)), + Cell::new(cache_interpretation), + ]); + + // Responses per prompt (tool use indicator) + if total_user_msgs > 0 { + let responses_per_prompt = total_assistant_msgs as f64 / total_user_msgs as f64; + let interpretation = if responses_per_prompt > 3.0 { + "Heavy tool use" + } else if responses_per_prompt > 1.5 { + "Moderate tool use" + } else { + "Minimal tool use" + }; + table.add_row(vec![ + Cell::new("Responses per Prompt"), + Cell::new(format!("{:.2}", responses_per_prompt)), + Cell::new(interpretation), + ]); + } + + // Prompts per session + if !recent.is_empty() { + let prompts_per_session = total_user_msgs as f64 / recent.len() as f64; + let interpretation = if prompts_per_session > 50.0 { + "Long sessions" + } else if prompts_per_session > 15.0 { + "Medium sessions" + } else { + "Short sessions" + }; + table.add_row(vec![ + Cell::new("Prompts per Session"), + Cell::new(format!("{:.1}", prompts_per_session)), + Cell::new(interpretation), + ]); + } + + // Time per prompt + if total_user_msgs > 0 && total_minutes > 0 { + let secs_per_prompt = (total_minutes * 60) as f64 / total_user_msgs as f64; + let interpretation = if secs_per_prompt > 120.0 { + "Slow pace" + } else if secs_per_prompt > 30.0 { + "Moderate pace" + } else { + "Fast pace" + }; + table.add_row(vec![ + Cell::new("Avg Time per Prompt"), + Cell::new(format!("{:.0}s", secs_per_prompt)), + Cell::new(interpretation), + ]); + } + + println!("{table}\n"); + + // Cost savings from caching + if total_cache_read > 0 { + // Opus 4.5: $15/1M input, cache read is 90% cheaper ($1.50/1M) + let savings = total_cache_read as f64 * (0.000015 - 0.0000015); + println!("{}", " CACHE SAVINGS".bold().green()); + println!("{}", "─".repeat(40).green()); + println!( + " Tokens served from cache: {}", + format_tokens(total_cache_read).blue() + ); + println!( + " Estimated savings: ${}", + format!("{:.2}", savings).green() + ); + println!(); + } +} + +fn print_hourly(hourly: &[u64; 24]) { + println!("\n{}", " ACTIVITY BY HOUR (Local Time)".bold().cyan()); + println!("{}", "─".repeat(50).cyan()); + + let max_activity = *hourly.iter().max().unwrap_or(&1); + + for (hour, &count) in hourly.iter().enumerate() { + let bar_len = if max_activity > 0 { + (count as f64 / max_activity as f64 * 30.0) as usize + } else { + 0 + }; + + let bar = "█".repeat(bar_len); + let color_bar = if hour >= 9 && hour < 17 { + bar.green() // Work hours + } else if hour >= 6 && hour < 22 { + bar.yellow() // Waking hours + } else { + bar.red() // Late night + }; + + println!( + " {:02}:00 │ {:>6} │ {}", + hour, + count.to_formatted_string(&Locale::en), + color_bar + ); + } + println!(); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// JSON OUTPUT FUNCTIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +fn json_error(code: u8, message: &str, suggestions: Vec<&str>) -> String { + let output: JsonOutput<()> = JsonOutput { + ok: false, + data: None, + error: Some(JsonError { + code, + message: message.to_string(), + suggestions: suggestions.into_iter().map(String::from).collect(), + }), + meta: None, + }; + serde_json::to_string(&output).unwrap_or_else(|_| r#"{"ok":false,"error":{"code":99,"message":"serialization failed"}}"#.to_string()) +} + +fn json_success(data: T, meta: JsonMeta) -> String { + let output = JsonOutput { + ok: true, + data: Some(data), + error: None, + meta: Some(meta), + }; + serde_json::to_string(&output).unwrap_or_else(|_| r#"{"ok":false,"error":{"code":99,"message":"serialization failed"}}"#.to_string()) +} + +fn build_summary_json(sessions: &[SessionStats], days: u32) -> SummaryJson { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let recent: Vec<_> = sessions + .iter() + .filter(|s| s.start_time.is_some_and(|t| t >= cutoff)) + .collect(); + + let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum(); + let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum(); + let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum(); + let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum(); + let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum(); + let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum(); + let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum(); + let total_tokens = total_input + total_output + total_cache_creation; + + let unique_days = recent + .iter() + .filter_map(|s| s.start_time) + .map(|t| t.with_timezone(&Local).date_naive()) + .collect::>() + .len() + .max(1); + + let cache_hit_rate = if total_cache_creation + total_cache_read > 0 { + (total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0 + } else { + 0.0 + }; + + let input_cost = total_input as f64 * 0.000015; + let output_cost = total_output as f64 * 0.000075; + let cache_write_cost = total_cache_creation as f64 * 0.00001875; + let cache_read_cost = total_cache_read as f64 * 0.0000015; + + SummaryJson { + sessions: recent.len() as u64, + prompts: total_user_msgs, + responses: total_assistant_msgs, + total_minutes, + tokens: TokensJson { + input: total_input, + output: total_output, + cache_creation: total_cache_creation, + cache_read: total_cache_read, + total_billed: total_tokens, + }, + averages: AveragesJson { + sessions_per_day: recent.len() as f64 / unique_days as f64, + prompts_per_day: total_user_msgs as f64 / unique_days as f64, + tokens_per_day: total_tokens / unique_days as u64, + minutes_per_day: total_minutes / unique_days as i64, + }, + efficiency: EfficiencyJson { + tokens_per_prompt: if total_user_msgs > 0 { total_tokens / total_user_msgs } else { 0 }, + output_per_prompt: if total_user_msgs > 0 { total_output / total_user_msgs } else { 0 }, + cache_hit_rate, + responses_per_prompt: if total_user_msgs > 0 { total_assistant_msgs as f64 / total_user_msgs as f64 } else { 0.0 }, + }, + cost_estimate: CostJson { + input: input_cost, + output: output_cost, + cache_write: cache_write_cost, + cache_read: cache_read_cost, + total: input_cost + output_cost + cache_write_cost + cache_read_cost, + }, + } +} + +fn build_daily_json(daily: &BTreeMap) -> Vec { + daily + .iter() + .rev() + .map(|(date, stats)| DailyJson { + date: date.format("%Y-%m-%d").to_string(), + sessions: stats.sessions, + prompts: stats.user_messages, + tokens: stats.total_tokens(), + minutes: stats.total_minutes, + }) + .collect() +} + +fn build_weekly_json(weekly: &BTreeMap) -> Vec { + weekly + .iter() + .rev() + .map(|(week, stats)| WeeklyJson { + week: format!("{}-W{:02}", week.year(), week.week()), + sessions: stats.sessions, + prompts: stats.user_messages, + tokens: stats.total_tokens, + minutes: stats.total_minutes, + }) + .collect() +} + +fn build_sessions_json(sessions: &[SessionStats], count: usize) -> Vec { + let mut recent: Vec<_> = sessions.iter().collect(); + recent.sort_by(|a, b| b.start_time.cmp(&a.start_time)); + recent.truncate(count); + + recent + .iter() + .map(|s| SessionJson { + id: s.session_id.clone(), + project: s.project.clone(), + start: s.start_time.map(|t| t.to_rfc3339()), + duration_minutes: s.duration_minutes(), + prompts: s.user_messages, + tokens: s.total_tokens(), + }) + .collect() +} + +fn build_projects_json(projects: &[ProjectStats], limit: usize) -> Vec { + projects + .iter() + .take(limit) + .map(|p| ProjectJson { + name: p.name.clone(), + sessions: p.sessions, + messages: p.total_messages, + tokens: p.total_tokens, + }) + .collect() +} + +fn build_models_json(models: &[ModelStats]) -> Vec { + let total: u64 = models.iter().map(|m| m.requests).sum(); + models + .iter() + .take(10) + .map(|m| ModelJson { + name: m.name.clone(), + requests: m.requests, + percent: if total > 0 { (m.requests as f64 / total as f64) * 100.0 } else { 0.0 }, + }) + .collect() +} + +fn build_hourly_json(hourly: &[u64; 24]) -> Vec { + hourly + .iter() + .enumerate() + .map(|(hour, &messages)| HourlyJson { + hour: hour as u8, + messages, + }) + .collect() +} + +fn build_efficiency_json(sessions: &[SessionStats], days: u32) -> EfficiencyDetailJson { + let cutoff = Local::now().with_timezone(&Utc) - Duration::days(days as i64); + let recent: Vec<_> = sessions + .iter() + .filter(|s| s.start_time.is_some_and(|t| t >= cutoff)) + .collect(); + + let total_input: u64 = recent.iter().map(|s| s.input_tokens).sum(); + let total_output: u64 = recent.iter().map(|s| s.output_tokens).sum(); + let total_cache_creation: u64 = recent.iter().map(|s| s.cache_creation_tokens).sum(); + let total_cache_read: u64 = recent.iter().map(|s| s.cache_read_tokens).sum(); + let total_user_msgs: u64 = recent.iter().map(|s| s.user_messages).sum(); + let total_assistant_msgs: u64 = recent.iter().map(|s| s.assistant_messages).sum(); + let total_minutes: i64 = recent.iter().filter_map(|s| s.duration_minutes()).sum(); + let total_tokens = total_input + total_output + total_cache_creation; + + let tokens_per_prompt = if total_user_msgs > 0 { total_tokens as f64 / total_user_msgs as f64 } else { 0.0 }; + let output_per_prompt = if total_user_msgs > 0 { total_output as f64 / total_user_msgs as f64 } else { 0.0 }; + let cache_hit_rate = if total_cache_creation + total_cache_read > 0 { + (total_cache_read as f64 / (total_cache_creation + total_cache_read) as f64) * 100.0 + } else { + 0.0 + }; + let responses_per_prompt = if total_user_msgs > 0 { total_assistant_msgs as f64 / total_user_msgs as f64 } else { 0.0 }; + let prompts_per_session = if !recent.is_empty() { total_user_msgs as f64 / recent.len() as f64 } else { 0.0 }; + let secs_per_prompt = if total_user_msgs > 0 && total_minutes > 0 { (total_minutes * 60) as f64 / total_user_msgs as f64 } else { 0.0 }; + let cache_savings = total_cache_read as f64 * (0.000015 - 0.0000015); + + EfficiencyDetailJson { + tokens_per_prompt: MetricJson { + value: tokens_per_prompt, + interpretation: if tokens_per_prompt > 50000.0 { "heavy_context" } else if tokens_per_prompt > 20000.0 { "moderate_context" } else { "light_usage" }.to_string(), + }, + output_per_prompt: MetricJson { + value: output_per_prompt, + interpretation: if output_per_prompt > 1000.0 { "detailed_responses" } else if output_per_prompt > 300.0 { "moderate_responses" } else { "brief_responses" }.to_string(), + }, + cache_hit_rate: MetricJson { + value: cache_hit_rate, + interpretation: if cache_hit_rate > 90.0 { "excellent" } else if cache_hit_rate > 70.0 { "good" } else { "low_reuse" }.to_string(), + }, + responses_per_prompt: MetricJson { + value: responses_per_prompt, + interpretation: if responses_per_prompt > 3.0 { "heavy_tool_use" } else if responses_per_prompt > 1.5 { "moderate_tool_use" } else { "minimal_tool_use" }.to_string(), + }, + prompts_per_session: MetricJson { + value: prompts_per_session, + interpretation: if prompts_per_session > 50.0 { "long_sessions" } else if prompts_per_session > 15.0 { "medium_sessions" } else { "short_sessions" }.to_string(), + }, + seconds_per_prompt: MetricJson { + value: secs_per_prompt, + interpretation: if secs_per_prompt > 120.0 { "slow_pace" } else if secs_per_prompt > 30.0 { "moderate_pace" } else { "fast_pace" }.to_string(), + }, + cache_savings_usd: cache_savings, + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ERROR TOLERANCE & ARGUMENT NORMALIZATION +// ═══════════════════════════════════════════════════════════════════════════════ + +/// Normalize common argument mistakes that agents make +fn normalize_args(args: Vec) -> (Vec, Option) { + let mut normalized = Vec::new(); + let mut warning: Option = None; + let mut i = 0; + + // Known subcommands and their common misspellings/aliases + let subcommand_aliases: HashMap<&str, &str> = [ + // Exact matches (canonical) + ("tui", "tui"), + ("daily", "daily"), + ("weekly", "weekly"), + ("sessions", "sessions"), + ("projects", "projects"), + ("hourly", "hourly"), + ("models", "models"), + ("efficiency", "efficiency"), + ("all", "all"), + // TUI aliases + ("dashboard", "tui"), + ("interactive", "tui"), + ("ui", "tui"), + // Common misspellings + ("dailly", "daily"), + ("daly", "daily"), + ("dayly", "daily"), + ("weekley", "weekly"), + ("weakly", "weekly"), + ("session", "sessions"), + ("sess", "sessions"), + ("project", "projects"), + ("proj", "projects"), + ("hour", "hourly"), + ("hours", "hourly"), + ("model", "models"), + ("mod", "models"), + ("eff", "efficiency"), + ("efficency", "efficiency"), + ("efficient", "efficiency"), + ("summary", "all"), + ("sum", "all"), + ("stats", "all"), + ("overview", "all"), + ("total", "all"), + ("totals", "all"), + ] + .into_iter() + .collect(); + + // Flag aliases + let flag_aliases: HashMap<&str, &str> = [ + // JSON output variants + ("--json", "--json"), + ("-json", "--json"), + ("--JSON", "--json"), + ("-J", "--json"), + ("--output=json", "--json"), + ("--format=json", "--json"), + ("--format", "--json"), // assume json if just --format + // Quiet variants + ("--quiet", "--quiet"), + ("-quiet", "--quiet"), + ("-q", "--quiet"), + ("--silent", "--quiet"), + ("-s", "--quiet"), + // Days variants + ("--days", "--days"), + ("-days", "--days"), + ("-d", "--days"), + ("--day", "--days"), + ("-n", "-n"), + ("--count", "-n"), + ("-c", "-n"), + ("--limit", "-n"), + ("-l", "-n"), + // Help variants + ("--help", "--help"), + ("-help", "--help"), + ("-h", "--help"), + ("help", "--help"), + ("-?", "--help"), + ] + .into_iter() + .collect(); + + while i < args.len() { + let arg = &args[i]; + let arg_lower = arg.to_lowercase(); + + // Skip the binary name + if i == 0 { + normalized.push(arg.clone()); + i += 1; + continue; + } + + // Check for subcommand aliases + if let Some(&canonical) = subcommand_aliases.get(arg_lower.as_str()) { + if canonical != arg_lower { + warning = Some(format!( + "Interpreted '{}' as '{}'. Use '{}' directly.", + arg, canonical, canonical + )); + } + normalized.push(canonical.to_string()); + i += 1; + continue; + } + + // Check for flag aliases + if let Some(&canonical) = flag_aliases.get(arg_lower.as_str()) { + if canonical != arg { + warning = Some(format!( + "Interpreted '{}' as '{}'. Use '{}' directly.", + arg, canonical, canonical + )); + } + normalized.push(canonical.to_string()); + i += 1; + continue; + } + + // Handle --flag=value patterns + if arg.contains('=') { + let parts: Vec<&str> = arg.splitn(2, '=').collect(); + if parts.len() == 2 { + let flag_lower = parts[0].to_lowercase(); + if let Some(&canonical) = flag_aliases.get(flag_lower.as_str()) { + // Handle --days=N or --count=N + if canonical == "--days" || canonical == "-n" { + if let Ok(_) = parts[1].parse::() { + normalized.push(canonical.to_string()); + normalized.push(parts[1].to_string()); + i += 1; + continue; + } + } + } + } + } + + // Handle -nN pattern (e.g., -n7 instead of -n 7) + if arg.starts_with("-n") && arg.len() > 2 { + let num_part = &arg[2..]; + if let Ok(_) = num_part.parse::() { + normalized.push("-n".to_string()); + normalized.push(num_part.to_string()); + warning = Some(format!( + "Interpreted '{}' as '-n {}'. Use '-n {}' with a space.", + arg, num_part, num_part + )); + i += 1; + continue; + } + } + + // Handle common typos in numbers (e.g., "7days" -> "7") + if arg.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) { + let num_str: String = arg.chars().take_while(|c| c.is_ascii_digit()).collect(); + if num_str.len() != arg.len() { + if let Ok(_) = num_str.parse::() { + normalized.push(num_str.clone()); + warning = Some(format!( + "Interpreted '{}' as '{}'. Use just the number.", + arg, num_str + )); + i += 1; + continue; + } + } + } + + // Pass through unchanged + normalized.push(arg.clone()); + i += 1; + } + + (normalized, warning) +} + +/// Generate a helpful error message for invalid commands +fn helpful_error(attempted: &str, json_mode: bool) -> String { + let attempted_lower = attempted.to_lowercase(); + + // Try to guess what they wanted + let suggestions = if attempted_lower.contains("day") || attempted_lower.contains("daily") { + vec![ + "claude-stats daily # Last 14 days breakdown", + "claude-stats daily -n 7 # Last 7 days breakdown", + "claude-stats daily --json # JSON output", + ] + } else if attempted_lower.contains("week") { + vec![ + "claude-stats weekly # Last 8 weeks breakdown", + "claude-stats weekly -n 4 # Last 4 weeks breakdown", + ] + } else if attempted_lower.contains("sess") { + vec![ + "claude-stats sessions # Last 20 sessions", + "claude-stats sessions -n 10 # Last 10 sessions", + ] + } else if attempted_lower.contains("proj") { + vec![ + "claude-stats projects # Token usage by project", + ] + } else if attempted_lower.contains("hour") { + vec![ + "claude-stats hourly # Activity by hour", + ] + } else if attempted_lower.contains("model") { + vec![ + "claude-stats models # Model usage breakdown", + ] + } else if attempted_lower.contains("eff") || attempted_lower.contains("cache") || attempted_lower.contains("cost") { + vec![ + "claude-stats efficiency # Cache hit rate, cost savings", + ] + } else if attempted_lower.contains("token") || attempted_lower.contains("usage") || attempted_lower.contains("stat") { + vec![ + "claude-stats # Summary with all token stats", + "claude-stats all # Comprehensive statistics", + "claude-stats --json # JSON format for agents", + ] + } else { + vec![ + "claude-stats # Summary (default)", + "claude-stats daily -n 7 # Daily breakdown", + "claude-stats --json # JSON output for agents", + "claude-stats --help # Full command list", + ] + }; + + if json_mode { + let error = JsonError { + code: EXIT_INVALID_ARGS, + message: format!("Unknown command or option: '{}'", attempted), + suggestions: suggestions.iter().map(|s| s.to_string()).collect(), + }; + let output: JsonOutput<()> = JsonOutput { + ok: false, + data: None, + error: Some(error), + meta: None, + }; + serde_json::to_string(&output).unwrap_or_default() + } else { + let mut msg = format!("{}: Unknown command or option '{}'\n\n", "Error".red().bold(), attempted); + msg.push_str(&format!("{}\n", "Did you mean one of these?".yellow())); + for s in suggestions { + msg.push_str(&format!(" {}\n", s)); + } + msg.push_str(&format!("\nRun {} for full command list.", "claude-stats --help".cyan())); + msg + } +} + +/// Parse CLI with error tolerance +fn parse_cli_tolerant() -> Result<(Cli, Option), (String, bool)> { + let raw_args: Vec = env::args().collect(); + + // Check if JSON mode is requested (for error formatting) + let json_mode = raw_args.iter().any(|a| { + let lower = a.to_lowercase(); + lower == "--json" || lower == "-json" || lower == "-j" || + lower.contains("format=json") || lower.contains("output=json") + }) || !std::io::stdout().is_terminal(); + + // Normalize arguments + let (normalized, warning) = normalize_args(raw_args); + + // Try to parse + match Cli::try_parse_from(&normalized) { + Ok(cli) => Ok((cli, warning)), + Err(e) => { + // Handle help/version specially - let clap print its output + if e.kind() == clap::error::ErrorKind::DisplayHelp || + e.kind() == clap::error::ErrorKind::DisplayVersion { + e.exit(); + } + // Extract the problematic argument if possible + let err_str = e.to_string(); + + // Check for common clap error patterns + if err_str.contains("unexpected argument") || err_str.contains("invalid value") { + // Try to extract what they typed + if let Some(start) = err_str.find('\'') { + if let Some(end) = err_str[start+1..].find('\'') { + let attempted = &err_str[start+1..start+1+end]; + return Err((helpful_error(attempted, json_mode), json_mode)); + } + } + } + + // Generic error with examples + if json_mode { + Err((json_error( + EXIT_INVALID_ARGS, + &format!("Failed to parse arguments: {}", e.kind()), + vec![ + "claude-stats # Summary", + "claude-stats daily -n 7 # Daily breakdown", + "claude-stats --json # JSON output", + "claude-stats --help # Full help", + ] + ), true)) + } else { + let mut msg = format!("{}: {}\n\n", "Error".red().bold(), e.kind()); + msg.push_str(&format!("{}\n", "Quick examples:".yellow())); + msg.push_str(" claude-stats # Summary\n"); + msg.push_str(" claude-stats daily -n 7 # Daily breakdown\n"); + msg.push_str(" claude-stats --json # JSON output\n"); + msg.push_str(&format!("\nRun {} for full help.", "claude-stats --help".cyan())); + Err((msg, false)) + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MAIN +// ═══════════════════════════════════════════════════════════════════════════════ + +fn main() -> ExitCode { + // Parse with error tolerance + let (cli, arg_warning) = match parse_cli_tolerant() { + Ok(result) => result, + Err((msg, is_json)) => { + if is_json { + println!("{}", msg); + } else { + eprintln!("{}", msg); + } + return ExitCode::from(EXIT_INVALID_ARGS); + } + }; + + // TTY detection: auto-enable JSON when piped + let json_mode = cli.json || !std::io::stdout().is_terminal(); + let quiet = cli.quiet || json_mode; + + // Print warning about argument normalization (in human mode only) + if let Some(warning) = arg_warning { + if !json_mode { + eprintln!("{}: {}\n", "Note".yellow().bold(), warning); + } + } + + // Get Claude directory + let claude_dir = match get_claude_dir(cli.data_dir) { + Ok(dir) => dir, + Err(e) => { + if json_mode { + println!("{}", json_error( + EXIT_DATA_DIR_NOT_FOUND, + &format!("Failed to locate Claude directory: {}", e), + vec!["Check if ~/.claude exists", "Use --data-dir to specify path"] + )); + } else { + eprintln!("{}: {}", "Error".red().bold(), e); + } + return ExitCode::from(EXIT_DATA_DIR_NOT_FOUND); + } + }; + + if !claude_dir.exists() { + if json_mode { + println!("{}", json_error( + EXIT_DATA_DIR_NOT_FOUND, + &format!("Claude data directory not found at {:?}", claude_dir), + vec!["Run Claude Code at least once to generate data", "Check --data-dir path"] + )); + } else { + eprintln!("{}: Claude data directory not found at {:?}", "Error".red().bold(), claude_dir); + } + return ExitCode::from(EXIT_DATA_DIR_NOT_FOUND); + } + + // Handle TUI mode separately - it loads its own data + if matches!(cli.command, Some(Commands::Tui)) { + match tui::run_tui(claude_dir, cli.days) { + Ok(()) => return ExitCode::from(EXIT_SUCCESS), + Err(e) => { + eprintln!("{}: TUI error: {}", "Error".red().bold(), e); + return ExitCode::from(EXIT_PARSE_ERROR); + } + } + } + + if !quiet { + println!("\n{}", "Scanning Claude Code session files...".dimmed()); + } + + let session_files = find_session_files(&claude_dir); + let file_count = session_files.len(); + + if file_count == 0 { + if json_mode { + println!("{}", json_error( + EXIT_NO_DATA, + "No session files found", + vec!["Ensure Claude Code has been used", "Check data directory path"] + )); + } else { + eprintln!("{}: No session files found", "Error".red().bold()); + } + return ExitCode::from(EXIT_NO_DATA); + } + + if !quiet { + println!("{}", format!("Found {} session files", file_count).dimmed()); + } + + // Parse sessions (with or without progress bar) + let sessions: Vec = if quiet { + session_files + .par_iter() + .filter_map(|path| parse_session_file(path).ok()) + .collect() + } else { + let pb = ProgressBar::new(file_count as u64); + if let Ok(style) = ProgressStyle::default_bar() + .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})") + { + pb.set_style(style.progress_chars("#>-")); + } + let result: Vec = session_files + .par_iter() + .progress_with(pb) + .filter_map(|path| parse_session_file(path).ok()) + .collect(); + result + }; + + let session_count = sessions.len(); + + if session_count == 0 { + if json_mode { + println!("{}", json_error( + EXIT_PARSE_ERROR, + "Failed to parse any session files", + vec!["Session files may be corrupted", "Try with a different --data-dir"] + )); + } else { + eprintln!("{}: Failed to parse any session files", "Error".red().bold()); + } + return ExitCode::from(EXIT_PARSE_ERROR); + } + + if !quiet { + println!("{}", format!("Parsed {} sessions successfully\n", session_count).dimmed()); + } + + // Build metadata for JSON responses + let build_meta = |days: u32| JsonMeta { + sessions_parsed: session_count, + files_scanned: file_count, + period_days: days, + }; + + // Execute command + match cli.command { + None | Some(Commands::All) => { + if json_mode { + let data = build_summary_json(&sessions, cli.days); + println!("{}", json_success(data, build_meta(cli.days))); + } else { + print_summary(&sessions, cli.days); + let daily = aggregate_daily(&sessions, 14); + print_daily(&daily); + print_sessions(&sessions, 10); + let projects = aggregate_projects(&sessions); + print_projects(&projects, 10); + let models = aggregate_models(&sessions, cli.days); + print_models(&models); + let hourly = aggregate_hourly(&sessions, cli.days); + print_hourly(&hourly); + } + } + Some(Commands::Daily { days }) => { + let daily = aggregate_daily(&sessions, days); + if json_mode { + let data = build_daily_json(&daily); + println!("{}", json_success(data, build_meta(days))); + } else { + print_daily(&daily); + } + } + Some(Commands::Weekly { weeks }) => { + let weekly = aggregate_weekly(&sessions, weeks); + if json_mode { + let data = build_weekly_json(&weekly); + println!("{}", json_success(data, build_meta(weeks * 7))); + } else { + print_weekly(&weekly); + } + } + Some(Commands::Sessions { count }) => { + if json_mode { + let data = build_sessions_json(&sessions, count); + println!("{}", json_success(data, build_meta(cli.days))); + } else { + print_sessions(&sessions, count); + } + } + Some(Commands::Projects) => { + let projects = aggregate_projects(&sessions); + if json_mode { + let data = build_projects_json(&projects, 20); + println!("{}", json_success(data, build_meta(cli.days))); + } else { + print_projects(&projects, 20); + } + } + Some(Commands::Hourly) => { + let hourly = aggregate_hourly(&sessions, cli.days); + if json_mode { + let data = build_hourly_json(&hourly); + println!("{}", json_success(data, build_meta(cli.days))); + } else { + print_hourly(&hourly); + } + } + Some(Commands::Models) => { + let models = aggregate_models(&sessions, cli.days); + if json_mode { + let data = build_models_json(&models); + println!("{}", json_success(data, build_meta(cli.days))); + } else { + print_models(&models); + } + } + Some(Commands::Efficiency) => { + if json_mode { + let data = build_efficiency_json(&sessions, cli.days); + println!("{}", json_success(data, build_meta(cli.days))); + } else { + print_efficiency(&sessions, cli.days); + } + } + Some(Commands::Tui) => { + // Already handled earlier in the function + unreachable!("TUI command should be handled before session parsing"); + } + } + + ExitCode::from(EXIT_SUCCESS) +} diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..b65e023 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,443 @@ +// TUI Application state and event handling + +use crate::data::*; +use crate::tui::report::generate_report; +use crate::tui::views::*; +use anyhow::Result; +use arboard::Clipboard; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Tabs, Clear}, + Frame, Terminal, +}; +use std::io; +use std::path::PathBuf; +use std::time::Duration; + +// ═══════════════════════════════════════════════════════════════════════════════ +// APP STATE +// ═══════════════════════════════════════════════════════════════════════════════ + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum View { + Summary, + Daily, + Weekly, + Projects, + Models, + Hourly, + Report, +} + +impl View { + fn all() -> Vec { + vec![ + View::Summary, + View::Daily, + View::Weekly, + View::Projects, + View::Models, + View::Hourly, + View::Report, + ] + } + + fn title(&self) -> &'static str { + match self { + View::Summary => "Summary", + View::Daily => "Daily", + View::Weekly => "Weekly", + View::Projects => "Projects", + View::Models => "Models", + View::Hourly => "Hourly", + View::Report => "Report", + } + } + + fn index(&self) -> usize { + match self { + View::Summary => 0, + View::Daily => 1, + View::Weekly => 2, + View::Projects => 3, + View::Models => 4, + View::Hourly => 5, + View::Report => 6, + } + } +} + +pub struct App { + pub sessions: Vec, + pub file_count: usize, + pub current_view: View, + pub days: u32, + pub scroll_offset: usize, + pub show_help: bool, + pub status_message: Option<(String, std::time::Instant)>, + pub report_content: String, +} + +impl App { + pub fn new(sessions: Vec, file_count: usize, days: u32) -> Self { + Self { + sessions, + file_count, + current_view: View::Summary, + days, + scroll_offset: 0, + show_help: false, + status_message: None, + report_content: String::new(), + } + } + + pub fn next_view(&mut self) { + let views = View::all(); + let current_idx = self.current_view.index(); + self.current_view = views[(current_idx + 1) % views.len()]; + self.scroll_offset = 0; + } + + pub fn prev_view(&mut self) { + let views = View::all(); + let current_idx = self.current_view.index(); + self.current_view = views[(current_idx + views.len() - 1) % views.len()]; + self.scroll_offset = 0; + } + + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + + pub fn page_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(10); + } + + pub fn page_down(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_add(10); + } + + pub fn increase_days(&mut self) { + self.days = (self.days + 7).min(365); + } + + pub fn decrease_days(&mut self) { + self.days = self.days.saturating_sub(7).max(1); + } + + pub fn set_status(&mut self, msg: &str) { + self.status_message = Some((msg.to_string(), std::time::Instant::now())); + } + + pub fn copy_to_clipboard(&mut self) -> Result<()> { + use base64::Engine; + use std::io::Write; + + let report = generate_report(&self.sessions, self.days); + self.report_content = report.clone(); + + // Try OSC 52 first (works over SSH with modern terminals like WezTerm) + let osc52_result = (|| -> Result<()> { + let encoded = base64::engine::general_purpose::STANDARD.encode(&report); + let mut stdout = std::io::stdout(); + // OSC 52 format: ESC ] 52 ; c ; BEL + write!(stdout, "\x1b]52;c;{}\x07", encoded)?; + stdout.flush()?; + Ok(()) + })(); + + if osc52_result.is_ok() { + self.set_status("✓ Report copied via OSC 52!"); + return Ok(()); + } + + // Fall back to arboard (X11/Wayland) + match Clipboard::new() { + Ok(mut clipboard) => { + clipboard.set_text(&report)?; + self.set_status("✓ Report copied to clipboard!"); + Ok(()) + } + Err(e) => { + self.set_status(&format!("✗ Clipboard error: {}", e)); + Err(e.into()) + } + } + } + + pub fn generate_report(&mut self) { + self.report_content = generate_report(&self.sessions, self.days); + self.current_view = View::Report; + self.scroll_offset = 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TUI RUNNER +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn run_tui(claude_dir: PathBuf, days: u32) -> Result<()> { + // Load data + let (sessions, file_count) = load_all_sessions(&claude_dir)?; + + if sessions.is_empty() { + anyhow::bail!("No sessions found in {:?}", claude_dir); + } + + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app + let mut app = App::new(sessions, file_count, days); + + // Run event loop + let result = run_app(&mut terminal, &mut app); + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + result +} + +fn run_app( + terminal: &mut Terminal, + app: &mut App, +) -> Result<()> { + loop { + terminal.draw(|f| draw_ui(f, app))?; + + // Clear expired status messages + if let Some((_, time)) = &app.status_message { + if time.elapsed() > Duration::from_secs(3) { + app.status_message = None; + } + } + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + // Handle help overlay first + if app.show_help { + app.show_help = false; + continue; + } + + match key.code { + // Quit + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(()) + } + + // Navigation + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => app.next_view(), + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => app.prev_view(), + + // Direct view access + KeyCode::Char('1') => app.current_view = View::Summary, + KeyCode::Char('2') => app.current_view = View::Daily, + KeyCode::Char('3') => app.current_view = View::Weekly, + KeyCode::Char('4') => app.current_view = View::Projects, + KeyCode::Char('5') => app.current_view = View::Models, + KeyCode::Char('6') => app.current_view = View::Hourly, + KeyCode::Char('7') => app.current_view = View::Report, + + // Scrolling + KeyCode::Up | KeyCode::Char('k') => app.scroll_up(), + KeyCode::Down | KeyCode::Char('j') => app.scroll_down(), + KeyCode::PageUp => app.page_up(), + KeyCode::PageDown => app.page_down(), + KeyCode::Home => app.scroll_offset = 0, + + // Time range + KeyCode::Char('+') | KeyCode::Char('=') => app.increase_days(), + KeyCode::Char('-') | KeyCode::Char('_') => app.decrease_days(), + + // Actions + KeyCode::Char('c') => { + let _ = app.copy_to_clipboard(); + } + KeyCode::Char('r') => app.generate_report(), + KeyCode::Char('?') => app.show_help = true, + + _ => {} + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// UI DRAWING +// ═══════════════════════════════════════════════════════════════════════════════ + +fn draw_ui(f: &mut Frame, app: &App) { + let size = f.area(); + + // Main layout: header, content, footer + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header/tabs + Constraint::Min(10), // Content + Constraint::Length(3), // Footer + ]) + .split(size); + + // Draw header with tabs + draw_header(f, app, chunks[0]); + + // Draw main content + draw_content(f, app, chunks[1]); + + // Draw footer + draw_footer(f, app, chunks[2]); + + // Draw help overlay if active + if app.show_help { + draw_help_overlay(f, size); + } +} + +fn draw_header(f: &mut Frame, app: &App, area: Rect) { + let titles: Vec = View::all() + .iter() + .enumerate() + .map(|(i, v)| { + let title = format!(" {} {} ", i + 1, v.title()); + Line::from(title) + }) + .collect(); + + let tabs = Tabs::new(titles) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Claude Code Stats ") + .title_style(Style::default().fg(Color::Cyan).bold()), + ) + .select(app.current_view.index()) + .style(Style::default().fg(Color::Gray)) + .highlight_style( + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + f.render_widget(tabs, area); +} + +fn draw_content(f: &mut Frame, app: &App, area: Rect) { + match app.current_view { + View::Summary => draw_summary_view(f, app, area), + View::Daily => draw_daily_view(f, app, area), + View::Weekly => draw_weekly_view(f, app, area), + View::Projects => draw_projects_view(f, app, area), + View::Models => draw_models_view(f, app, area), + View::Hourly => draw_hourly_view(f, app, area), + View::Report => draw_report_view(f, app, area), + } +} + +fn draw_footer(f: &mut Frame, app: &App, area: Rect) { + let status_text = if let Some((msg, _)) = &app.status_message { + msg.clone() + } else { + format!( + " {} days │ {} sessions │ Tab/←→: navigate │ c: copy │ r: report │ +/-: days │ ?: help │ q: quit", + app.days, + format_number(app.sessions.len() as u64) + ) + }; + + let status_style = if app.status_message.is_some() { + Style::default().fg(Color::Green).bold() + } else { + Style::default().fg(Color::DarkGray) + }; + + let footer = Paragraph::new(status_text) + .style(status_style) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + + f.render_widget(footer, area); +} + +fn draw_help_overlay(f: &mut Frame, area: Rect) { + // Center the help popup + let popup_width = 60.min(area.width - 4); + let popup_height = 20.min(area.height - 4); + let popup_x = (area.width - popup_width) / 2; + let popup_y = (area.height - popup_height) / 2; + + let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height); + + // Clear background + f.render_widget(Clear, popup_area); + + let help_text = vec![ + Line::from(Span::styled("KEYBOARD SHORTCUTS", Style::default().bold().fg(Color::Cyan))), + Line::from(""), + Line::from(vec![ + Span::styled("Navigation", Style::default().fg(Color::Yellow)), + ]), + Line::from(" Tab/→/l Next view"), + Line::from(" Shift+Tab/←/h Previous view"), + Line::from(" 1-7 Jump to view"), + Line::from(" ↑/k ↓/j Scroll up/down"), + Line::from(" PgUp/PgDn Page up/down"), + Line::from(""), + Line::from(vec![ + Span::styled("Actions", Style::default().fg(Color::Yellow)), + ]), + Line::from(" c Copy report to clipboard"), + Line::from(" r Generate report view"), + Line::from(" +/- Increase/decrease days"), + Line::from(""), + Line::from(vec![ + Span::styled("General", Style::default().fg(Color::Yellow)), + ]), + Line::from(" ? Show this help"), + Line::from(" q/Esc Quit"), + ]; + + let help = Paragraph::new(help_text) + .block( + Block::default() + .title(" Help ") + .title_style(Style::default().fg(Color::Cyan).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ) + .style(Style::default().fg(Color::White)); + + f.render_widget(help, popup_area); +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..4d6d523 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,7 @@ +// TUI module for claude-stats + +mod app; +mod views; +mod report; + +pub use app::run_tui; diff --git a/src/tui/report.rs b/src/tui/report.rs new file mode 100644 index 0000000..57587b2 --- /dev/null +++ b/src/tui/report.rs @@ -0,0 +1,204 @@ +// Report generation for clipboard export + +use crate::data::*; +use chrono::Local; + +/// Generate a well-formatted text report suitable for clipboard +pub fn generate_report(sessions: &[SessionStats], days: u32) -> String { + let summary = compute_summary(sessions, days); + let daily = aggregate_daily(sessions, days.min(14)); + let models = aggregate_models(sessions, days); + let projects = aggregate_projects(sessions); + + let now = Local::now(); + let mut report = String::new(); + + // Header + report.push_str(&format!( + "# Claude Code Usage Report\n\ + Generated: {}\n\ + Period: {} days\n\n", + now.format("%Y-%m-%d %H:%M"), + days + )); + + // Summary section + report.push_str("## Summary\n\n"); + report.push_str(&format!( + "┌─────────────────────────────────────────────────┐\n\ + │ Sessions: {:>12} │\n\ + │ Prompts: {:>12} │\n\ + │ Responses: {:>12} │\n\ + │ Active Days: {:>12} │\n\ + │ Total Time: {:>12} │\n\ + └─────────────────────────────────────────────────┘\n\n", + format_number(summary.total_sessions), + format_number(summary.total_prompts), + format_number(summary.total_responses), + summary.unique_days, + format_duration(summary.total_minutes), + )); + + // Token usage + report.push_str("## Token Usage\n\n"); + report.push_str(&format!( + "┌─────────────────────────────────────────────────┐\n\ + │ Input Tokens: {:>12} │\n\ + │ Output Tokens: {:>12} │\n\ + │ Cache Write: {:>12} │\n\ + │ Cache Read: {:>12} │\n\ + ├─────────────────────────────────────────────────┤\n\ + │ TOTAL BILLED: {:>12} │\n\ + └─────────────────────────────────────────────────┘\n\n", + format_tokens(summary.input_tokens), + format_tokens(summary.output_tokens), + format_tokens(summary.cache_creation_tokens), + format_tokens(summary.cache_read_tokens), + format_tokens(summary.total_tokens), + )); + + // Daily averages + report.push_str("## Daily Averages\n\n"); + report.push_str(&format!( + " Sessions/day: {:.1}\n\ + Prompts/day: {:.1}\n\ + Tokens/day: {}\n\ + Time/day: {}\n\n", + summary.sessions_per_day, + summary.prompts_per_day, + format_tokens(summary.tokens_per_day), + format_duration(summary.minutes_per_day), + )); + + // Efficiency metrics + report.push_str("## Efficiency\n\n"); + report.push_str(&format!( + " Tokens/prompt: {}\n\ + Output/prompt: {}\n\ + Responses/prompt: {:.2}\n\ + Cache hit rate: {:.1}%\n\n", + format_tokens(summary.tokens_per_prompt), + format_tokens(summary.output_per_prompt), + summary.responses_per_prompt, + summary.cache_hit_rate, + )); + + // Cost estimate + report.push_str("## API Cost Equivalent (Opus 4.5 Pricing)\n\n"); + report.push_str(&format!( + "┌─────────────────────────────────────────────────┐\n\ + │ Input: ${:>10.2} │\n\ + │ Output: ${:>10.2} │\n\ + │ Cache Write: ${:>10.2} │\n\ + │ Cache Read: ${:>10.2} │\n\ + ├─────────────────────────────────────────────────┤\n\ + │ TOTAL: ${:>10.2} │\n\ + │ Cache Savings: ${:>10.2} │\n\ + └─────────────────────────────────────────────────┘\n\n", + summary.input_cost, + summary.output_cost, + summary.cache_write_cost, + summary.cache_read_cost, + summary.total_cost, + summary.cache_savings, + )); + + // Daily breakdown (last 14 days max) + report.push_str("## Daily Breakdown\n\n"); + report.push_str(" Date │ Sessions │ Prompts │ Tokens │ Time\n"); + report.push_str(" ────────────┼──────────┼─────────┼──────────┼─────────\n"); + + for (date, stats) in daily.iter().rev().take(14) { + report.push_str(&format!( + " {} │ {:>8} │ {:>7} │ {:>8} │ {}\n", + date.format("%Y-%m-%d"), + stats.sessions, + stats.user_messages, + format_tokens(stats.total_tokens()), + format_duration(stats.total_minutes), + )); + } + report.push('\n'); + + // Model usage + if !models.is_empty() { + let total_requests: u64 = models.iter().map(|m| m.requests).sum(); + report.push_str("## Model Usage\n\n"); + report.push_str(" Model │ Requests │ %\n"); + report.push_str(" ───────────────────────────┼──────────┼────────\n"); + + for model in models.iter().take(5) { + let pct = if total_requests > 0 { + (model.requests as f64 / total_requests as f64) * 100.0 + } else { + 0.0 + }; + let short_name = model.name + .replace("claude-", "") + .replace("-20251101", "") + .replace("-20250929", "") + .replace("-20251001", "") + .replace("-20250514", ""); + + report.push_str(&format!( + " {:<26} │ {:>8} │ {:>5.1}%\n", + short_name, + format_number(model.requests), + pct, + )); + } + report.push('\n'); + } + + // Top projects + if !projects.is_empty() { + let total_tokens: u64 = projects.iter().map(|p| p.total_tokens).sum(); + report.push_str("## Top Projects\n\n"); + report.push_str(" Project │ Sessions │ Tokens │ %\n"); + report.push_str(" ──────────────────────────────────────┼──────────┼──────────┼────────\n"); + + for project in projects.iter().take(10) { + let pct = if total_tokens > 0 { + (project.total_tokens as f64 / total_tokens as f64) * 100.0 + } else { + 0.0 + }; + + let name = if project.name.len() > 38 { + format!("...{}", &project.name[project.name.len() - 35..]) + } else { + project.name.clone() + }; + + report.push_str(&format!( + " {:<38} │ {:>8} │ {:>8} │ {:>5.1}%\n", + name, + project.sessions, + format_tokens(project.total_tokens), + pct, + )); + } + report.push('\n'); + } + + // Footer + report.push_str("---\n"); + report.push_str("Generated by claude-stats v0.2.0\n"); + + report +} + +/// Generate a compact single-line summary +pub fn generate_compact_summary(sessions: &[SessionStats], days: u32) -> String { + let summary = compute_summary(sessions, days); + + format!( + "{}d: {} sessions, {} prompts, {} tokens, ${:.2} equiv, {:.1}% cache", + days, + format_number(summary.total_sessions), + format_number(summary.total_prompts), + format_tokens(summary.total_tokens), + summary.total_cost, + summary.cache_hit_rate, + ) +} diff --git a/src/tui/views.rs b/src/tui/views.rs new file mode 100644 index 0000000..d77df7a --- /dev/null +++ b/src/tui/views.rs @@ -0,0 +1,595 @@ +// TUI view rendering + +use crate::data::*; +use crate::tui::app::App; +use chrono::Datelike; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{ + Bar, BarChart, BarGroup, Block, Borders, Cell, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, Table, Wrap, + }, + Frame, +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// SUMMARY VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_summary_view(f: &mut Frame, app: &App, area: Rect) { + let summary = compute_summary(&app.sessions, app.days); + + // Split into two columns + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + // Left column: Overview metrics + let left_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), // Activity + Constraint::Length(9), // Tokens + Constraint::Min(6), // Averages + ]) + .split(columns[0]); + + // Right column: Cost and efficiency + let right_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(10), // Cost breakdown + Constraint::Min(7), // Efficiency + ]) + .split(columns[1]); + + // Activity panel + let activity_content = vec![ + Line::from(vec![ + Span::raw(" Sessions: "), + Span::styled(format_number(summary.total_sessions), Style::default().fg(Color::Green).bold()), + ]), + Line::from(vec![ + Span::raw(" Prompts: "), + Span::styled(format_number(summary.total_prompts), Style::default().fg(Color::Green).bold()), + ]), + Line::from(vec![ + Span::raw(" Responses: "), + Span::styled(format_number(summary.total_responses), Style::default().fg(Color::Green)), + ]), + Line::from(vec![ + Span::raw(" Total Time: "), + Span::styled(format_duration(summary.total_minutes), Style::default().fg(Color::Yellow)), + ]), + Line::from(vec![ + Span::raw(" Active Days: "), + Span::styled(format!("{}", summary.unique_days), Style::default().fg(Color::Cyan)), + ]), + ]; + + let activity_title = format!(" Activity ({} days) ", app.days); + let activity = Paragraph::new(activity_content) + .block(create_block(&activity_title)); + f.render_widget(activity, left_rows[0]); + + // Tokens panel + let tokens_content = vec![ + Line::from(vec![ + Span::raw(" Input: "), + Span::styled(format_tokens(summary.input_tokens), Style::default().fg(Color::Magenta)), + ]), + Line::from(vec![ + Span::raw(" Output: "), + Span::styled(format_tokens(summary.output_tokens), Style::default().fg(Color::Magenta)), + ]), + Line::from(vec![ + Span::raw(" Cache Write: "), + Span::styled(format_tokens(summary.cache_creation_tokens), Style::default().fg(Color::Magenta)), + ]), + Line::from(vec![ + Span::raw(" Cache Read: "), + Span::styled(format_tokens(summary.cache_read_tokens), Style::default().fg(Color::Blue)), + ]), + Line::from(""), + Line::from(vec![ + Span::raw(" TOTAL: "), + Span::styled(format_tokens(summary.total_tokens), Style::default().fg(Color::Yellow).bold()), + ]), + ]; + + let tokens = Paragraph::new(tokens_content) + .block(create_block(" Tokens ")); + f.render_widget(tokens, left_rows[1]); + + // Averages panel + let averages_content = vec![ + Line::from(vec![ + Span::raw(" Sessions/day: "), + Span::styled(format!("{:.1}", summary.sessions_per_day), Style::default().fg(Color::Green)), + ]), + Line::from(vec![ + Span::raw(" Prompts/day: "), + Span::styled(format!("{:.1}", summary.prompts_per_day), Style::default().fg(Color::Green)), + ]), + Line::from(vec![ + Span::raw(" Tokens/day: "), + Span::styled(format_tokens(summary.tokens_per_day), Style::default().fg(Color::Magenta)), + ]), + Line::from(vec![ + Span::raw(" Time/day: "), + Span::styled(format_duration(summary.minutes_per_day), Style::default().fg(Color::Yellow)), + ]), + ]; + + let averages = Paragraph::new(averages_content) + .block(create_block(" Daily Averages ")); + f.render_widget(averages, left_rows[2]); + + // Cost panel + let cost_content = vec![ + Line::from(vec![ + Span::raw(" Input: "), + Span::styled(format!("${:.2}", summary.input_cost), Style::default().fg(Color::Red)), + ]), + Line::from(vec![ + Span::raw(" Output: "), + Span::styled(format!("${:.2}", summary.output_cost), Style::default().fg(Color::Red)), + ]), + Line::from(vec![ + Span::raw(" Cache Write: "), + Span::styled(format!("${:.2}", summary.cache_write_cost), Style::default().fg(Color::Red)), + ]), + Line::from(vec![ + Span::raw(" Cache Read: "), + Span::styled(format!("${:.2}", summary.cache_read_cost), Style::default().fg(Color::Blue)), + ]), + Line::from(""), + Line::from(vec![ + Span::raw(" TOTAL: "), + Span::styled(format!("${:.2}", summary.total_cost), Style::default().fg(Color::Yellow).bold()), + ]), + Line::from(vec![ + Span::raw(" Savings: "), + Span::styled(format!("${:.2}", summary.cache_savings), Style::default().fg(Color::Green).bold()), + ]), + ]; + + let cost = Paragraph::new(cost_content) + .block(create_block(" API Cost Equivalent (Opus 4.5) ")); + f.render_widget(cost, right_rows[0]); + + // Efficiency panel + let cache_bar = create_percentage_bar(summary.cache_hit_rate, "Cache Hit"); + let efficiency_content = vec![ + Line::from(vec![ + Span::raw(" Tokens/prompt: "), + Span::styled(format_tokens(summary.tokens_per_prompt), Style::default().fg(Color::Magenta)), + ]), + Line::from(vec![ + Span::raw(" Output/prompt: "), + Span::styled(format_tokens(summary.output_per_prompt), Style::default().fg(Color::Magenta)), + ]), + Line::from(vec![ + Span::raw(" Responses/prompt: "), + Span::styled(format!("{:.2}", summary.responses_per_prompt), Style::default().fg(Color::Cyan)), + ]), + Line::from(""), + Line::from(vec![ + Span::raw(" Cache hit rate: "), + Span::styled(format!("{:.1}%", summary.cache_hit_rate), Style::default().fg(Color::Green).bold()), + ]), + Line::from(cache_bar), + ]; + + let efficiency = Paragraph::new(efficiency_content) + .block(create_block(" Efficiency ")); + f.render_widget(efficiency, right_rows[1]); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// DAILY VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_daily_view(f: &mut Frame, app: &App, area: Rect) { + let daily = aggregate_daily(&app.sessions, app.days); + + let header = Row::new(vec![ + Cell::from("Date").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Day").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Prompts").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Time").style(Style::default().fg(Color::Cyan).bold()), + ]) + .height(1) + .bottom_margin(1); + + let rows: Vec = daily + .iter() + .rev() + .skip(app.scroll_offset) + .map(|(date, stats)| { + let day_name = date.weekday().to_string(); + let day_short = day_name[..3].to_string(); + let is_weekend = date.weekday().num_days_from_monday() >= 5; + let day_style = if is_weekend { + Style::default().fg(Color::DarkGray) + } else { + Style::default() + }; + + Row::new(vec![ + Cell::from(date.format("%Y-%m-%d").to_string()), + Cell::from(day_short).style(day_style), + Cell::from(format_number(stats.sessions)).style(Style::default().fg(Color::Green)), + Cell::from(format_number(stats.user_messages)).style(Style::default().fg(Color::Green)), + Cell::from(format_tokens(stats.total_tokens())).style(Style::default().fg(Color::Magenta)), + Cell::from(format_duration(stats.total_minutes)).style(Style::default().fg(Color::Yellow)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(12), + Constraint::Length(5), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + ]; + + let daily_title = format!(" Daily Breakdown ({} days) ", app.days); + let table = Table::new(rows, widths) + .header(header) + .block(create_block(&daily_title)) + .row_highlight_style(Style::default().bg(Color::DarkGray)); + + f.render_widget(table, area); + + // Scrollbar + let total_items = daily.len(); + render_scrollbar(f, area, app.scroll_offset, total_items); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// WEEKLY VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_weekly_view(f: &mut Frame, app: &App, area: Rect) { + let weeks = (app.days / 7).max(1); + let weekly = aggregate_weekly(&app.sessions, weeks); + + let header = Row::new(vec![ + Cell::from("Week").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Prompts").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Time").style(Style::default().fg(Color::Cyan).bold()), + ]) + .height(1) + .bottom_margin(1); + + let rows: Vec = weekly + .iter() + .rev() + .skip(app.scroll_offset) + .map(|(week, stats)| { + Row::new(vec![ + Cell::from(format!("{}-W{:02}", week.year(), week.week())), + Cell::from(format_number(stats.sessions)).style(Style::default().fg(Color::Green)), + Cell::from(format_number(stats.user_messages)).style(Style::default().fg(Color::Green)), + Cell::from(format_tokens(stats.total_tokens)).style(Style::default().fg(Color::Magenta)), + Cell::from(format_duration(stats.total_minutes)).style(Style::default().fg(Color::Yellow)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Length(12), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(10), + ]; + + let weekly_title = format!(" Weekly Breakdown ({} weeks) ", weeks); + let table = Table::new(rows, widths) + .header(header) + .block(create_block(&weekly_title)); + + f.render_widget(table, area); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// PROJECTS VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_projects_view(f: &mut Frame, app: &App, area: Rect) { + let projects = aggregate_projects(&app.sessions); + let total_tokens: u64 = projects.iter().map(|p| p.total_tokens).sum(); + + let header = Row::new(vec![ + Cell::from("Project").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Sessions").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Messages").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Tokens").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("%").style(Style::default().fg(Color::Cyan).bold()), + ]) + .height(1) + .bottom_margin(1); + + let rows: Vec = projects + .iter() + .skip(app.scroll_offset) + .take(area.height.saturating_sub(4) as usize) + .map(|p| { + let pct = if total_tokens > 0 { + (p.total_tokens as f64 / total_tokens as f64) * 100.0 + } else { + 0.0 + }; + + // Truncate project name + let name = if p.name.len() > 40 { + format!("...{}", &p.name[p.name.len() - 37..]) + } else { + p.name.clone() + }; + + Row::new(vec![ + Cell::from(name), + Cell::from(format_number(p.sessions)).style(Style::default().fg(Color::Green)), + Cell::from(format_number(p.total_messages)).style(Style::default().fg(Color::Green)), + Cell::from(format_tokens(p.total_tokens)).style(Style::default().fg(Color::Magenta)), + Cell::from(format!("{:.1}%", pct)).style(Style::default().fg(Color::Yellow)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Min(30), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + ]; + + let projects_title = format!(" Projects ({} total) ", projects.len()); + let table = Table::new(rows, widths) + .header(header) + .block(create_block(&projects_title)); + + f.render_widget(table, area); + + render_scrollbar(f, area, app.scroll_offset, projects.len()); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// MODELS VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_models_view(f: &mut Frame, app: &App, area: Rect) { + let models = aggregate_models(&app.sessions, app.days); + let total: u64 = models.iter().map(|m| m.requests).sum(); + + // Split view: table on left, chart on right + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + // Table + let header = Row::new(vec![ + Cell::from("Model").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("Requests").style(Style::default().fg(Color::Cyan).bold()), + Cell::from("%").style(Style::default().fg(Color::Cyan).bold()), + ]) + .height(1) + .bottom_margin(1); + + let rows: Vec = models + .iter() + .take(10) + .map(|m| { + let pct = if total > 0 { + (m.requests as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + + // Shorten model name + let short_name = m.name + .replace("claude-", "") + .replace("-20251101", "") + .replace("-20250929", "") + .replace("-20251001", "") + .replace("-20250514", ""); + + Row::new(vec![ + Cell::from(short_name), + Cell::from(format_number(m.requests)).style(Style::default().fg(Color::Green)), + Cell::from(format!("{:.1}%", pct)).style(Style::default().fg(Color::Yellow)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Min(20), + Constraint::Length(12), + Constraint::Length(8), + ]; + + let table = Table::new(rows, widths) + .header(header) + .block(create_block(" Model Usage ")); + + f.render_widget(table, chunks[0]); + + // Bar chart + let bar_data: Vec<(&str, u64)> = models + .iter() + .take(5) + .map(|m| { + let short = m.name + .replace("claude-", "") + .replace("-20251101", "") + .replace("-20250929", "") + .replace("-20251001", "") + .replace("-20250514", ""); + // Leak the string to get a static reference (acceptable for TUI lifetime) + (Box::leak(short.into_boxed_str()) as &str, m.requests) + }) + .collect(); + + let bars: Vec = bar_data + .iter() + .map(|(label, value)| { + Bar::default() + .value(*value) + .label(Line::from(*label)) + .style(Style::default().fg(Color::Cyan)) + }) + .collect(); + + let chart = BarChart::default() + .block(create_block(" Distribution ")) + .data(BarGroup::default().bars(&bars)) + .bar_width(8) + .bar_gap(2) + .bar_style(Style::default().fg(Color::Cyan)) + .value_style(Style::default().fg(Color::White)); + + f.render_widget(chart, chunks[1]); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HOURLY VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_hourly_view(f: &mut Frame, app: &App, area: Rect) { + let hourly = aggregate_hourly(&app.sessions, app.days); + let max_activity = *hourly.iter().max().unwrap_or(&1); + + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + + for (hour, &count) in hourly.iter().enumerate() { + let bar_width = if max_activity > 0 { + ((count as f64 / max_activity as f64) * 40.0) as usize + } else { + 0 + }; + + let bar = "█".repeat(bar_width); + let bar_style = if hour >= 9 && hour < 17 { + Style::default().fg(Color::Green) // Work hours + } else if hour >= 6 && hour < 22 { + Style::default().fg(Color::Yellow) // Waking hours + } else { + Style::default().fg(Color::Red) // Late night + }; + + lines.push(Line::from(vec![ + Span::styled(format!(" {:02}:00 │ ", hour), Style::default().fg(Color::DarkGray)), + Span::styled(format!("{:>6} │ ", format_number(count)), Style::default().fg(Color::Cyan)), + Span::styled(bar, bar_style), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" Legend: ", Style::default().fg(Color::DarkGray)), + Span::styled("■", Style::default().fg(Color::Green)), + Span::raw(" Work (9-17) "), + Span::styled("■", Style::default().fg(Color::Yellow)), + Span::raw(" Day (6-22) "), + Span::styled("■", Style::default().fg(Color::Red)), + Span::raw(" Night"), + ])); + + let hourly_title = format!(" Activity by Hour ({} days) ", app.days); + let para = Paragraph::new(lines) + .block(create_block(&hourly_title)) + .scroll((app.scroll_offset as u16, 0)); + + f.render_widget(para, area); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// REPORT VIEW +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn draw_report_view(f: &mut Frame, app: &App, area: Rect) { + let content = if app.report_content.is_empty() { + "Press 'r' to generate a report, or 'c' to copy to clipboard.".to_string() + } else { + app.report_content.clone() + }; + + let lines: Vec = content + .lines() + .skip(app.scroll_offset) + .map(|line| { + // Style headers + if line.starts_with('#') { + Line::from(Span::styled(line, Style::default().fg(Color::Cyan).bold())) + } else if line.starts_with("│") || line.starts_with("├") || line.starts_with("└") || line.starts_with("┌") || line.starts_with("┐") || line.starts_with("┘") { + Line::from(Span::styled(line, Style::default().fg(Color::DarkGray))) + } else if line.contains(':') && !line.starts_with(' ') { + Line::from(Span::styled(line, Style::default().fg(Color::Yellow))) + } else { + Line::from(line) + } + }) + .collect(); + + let para = Paragraph::new(lines) + .block(create_block(" Report (c to copy) ")) + .wrap(Wrap { trim: false }); + + f.render_widget(para, area); + + let line_count = content.lines().count(); + render_scrollbar(f, area, app.scroll_offset, line_count); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// HELPERS +// ═══════════════════════════════════════════════════════════════════════════════ + +fn create_block(title: &str) -> Block<'_> { + Block::default() + .title(title) + .title_style(Style::default().fg(Color::Cyan).bold()) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) +} + +fn create_percentage_bar(percentage: f64, _label: &str) -> Line<'static> { + let filled = ((percentage / 100.0) * 30.0) as usize; + let empty = 30 - filled; + + Line::from(vec![ + Span::raw(" ["), + Span::styled("█".repeat(filled), Style::default().fg(Color::Green)), + Span::styled("░".repeat(empty), Style::default().fg(Color::DarkGray)), + Span::raw("]"), + ]) +} + +fn render_scrollbar(f: &mut Frame, area: Rect, offset: usize, total: usize) { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = ScrollbarState::new(total).position(offset); + + f.render_stateful_widget( + scrollbar, + area.inner(ratatui::layout::Margin { horizontal: 0, vertical: 1 }), + &mut scrollbar_state, + ); +}