diff --git a/.github/assets/client-devin.jpg b/.github/assets/client-devin.jpg new file mode 100644 index 000000000..22d2e67c6 Binary files /dev/null and b/.github/assets/client-devin.jpg differ diff --git a/.github/assets/client-jcode.png b/.github/assets/client-jcode.png new file mode 100644 index 000000000..0387a4dd5 Binary files /dev/null and b/.github/assets/client-jcode.png differ diff --git a/.github/assets/client-sakana.png b/.github/assets/client-sakana.png new file mode 100644 index 000000000..8b8086002 Binary files /dev/null and b/.github/assets/client-sakana.png differ diff --git a/.github/badges/coverage.svg b/.github/badges/coverage.svg index 8b97b282a..47dd89b54 100644 --- a/.github/badges/coverage.svg +++ b/.github/badges/coverage.svg @@ -1,5 +1,5 @@ - - coverage: 50% + + coverage: 57% @@ -13,8 +13,8 @@ \ No newline at end of file diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 3f758db92..8d1ad67b8 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -26,9 +26,15 @@ jobs: build: cargo build --release -p tokscale-cli --target x86_64-apple-darwin strip: strip -x target/x86_64-apple-darwin/release/tokscale bin_name: tokscale - - host: macos-latest + # Apple Silicon: build with the `apple-fm` feature so the on-device + # Apple Foundation Models summarizer is available. Requires the macOS + # 26 SDK + Swift toolchain (Xcode 26 on the macos-26 runner) to build + # the vendored foundation-models-c shim; statically linked, so the + # artifact stays a single self-contained binary. Apple Intelligence is + # Apple-Silicon-only, so this is intentionally not enabled for x86_64. + - host: macos-26 target: aarch64-apple-darwin - build: cargo build --release -p tokscale-cli --target aarch64-apple-darwin + build: cargo build --release -p tokscale-cli --target aarch64-apple-darwin --features apple-fm strip: strip -x target/aarch64-apple-darwin/release/tokscale bin_name: tokscale - host: ubuntu-latest diff --git a/.github/workflows/frontend_ci.yml b/.github/workflows/frontend_ci.yml index 3783ec227..758457b71 100644 --- a/.github/workflows/frontend_ci.yml +++ b/.github/workflows/frontend_ci.yml @@ -6,6 +6,8 @@ on: branches: [main, develop] paths: - "packages/frontend/**" + - "crates/tokscale-core/src/clients.rs" + - ".github/assets/**" - "package.json" - "bun.lock" - ".github/workflows/frontend_ci.yml" @@ -13,6 +15,8 @@ on: branches: [main, develop] paths: - "packages/frontend/**" + - "crates/tokscale-core/src/clients.rs" + - ".github/assets/**" - "package.json" - "bun.lock" - ".github/workflows/frontend_ci.yml" @@ -42,6 +46,9 @@ jobs: - name: Run frontend tests run: bun run --cwd packages/frontend test + - name: Run frontend type check + run: bun run --cwd packages/frontend typecheck + migration-replay: name: Frontend Migration Replay runs-on: ubuntu-latest diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index f7d9ebbfc..fc71dcad4 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -161,12 +161,18 @@ jobs: bin_name: tokscale build: cargo build --release -p tokscale-cli --target x86_64-apple-darwin strip: strip -x target/x86_64-apple-darwin/release/tokscale - - host: macos-latest + # Apple Silicon ships with the `apple-fm` feature (on-device Apple + # Foundation Models summarizer). Needs the macOS 26 SDK + Swift + # toolchain (Xcode 26 on macos-26) to build the vendored + # foundation-models-c shim; it is statically linked so the published + # binary stays self-contained. Not enabled for x86_64 — Apple + # Intelligence is Apple-Silicon-only (those installs use the heuristic). + - host: macos-26 target: aarch64-apple-darwin package_dir: cli-darwin-arm64 artifact_name: cli-binary-aarch64-apple-darwin bin_name: tokscale - build: cargo build --release -p tokscale-cli --target aarch64-apple-darwin + build: cargo build --release -p tokscale-cli --target aarch64-apple-darwin --features apple-fm strip: strip -x target/aarch64-apple-darwin/release/tokscale - host: ubuntu-latest target: x86_64-unknown-linux-gnu @@ -267,16 +273,67 @@ jobs: shell: bash run: ${{ matrix.settings.strip }} + # Stage the binary plus, for the apple-fm arm64 build, the sidecar + # libFoundationModels.dylib that build.rs places next to it. The dylib is + # dlopen'd at runtime (never linked), so it must travel inside the npm + # package alongside tokscale. Other targets stage only the binary. + - name: Stage release artifact + shell: bash + run: | + mkdir -p dist + cp "target/${{ matrix.settings.target }}/release/${{ matrix.settings.bin_name }}" dist/ + if [ -f "target/${{ matrix.settings.target }}/release/libFoundationModels.dylib" ]; then + cp "target/${{ matrix.settings.target }}/release/libFoundationModels.dylib" dist/ + echo "staged sidecar libFoundationModels.dylib" + fi + ls -la dist + - name: Upload CLI binary artifact uses: actions/upload-artifact@v6 with: name: ${{ matrix.settings.artifact_name }} - path: target/${{ matrix.settings.target }}/release/${{ matrix.settings.bin_name }} + path: dist if-no-files-found: error + smoke-release-artifacts: + name: Smoke release artifacts + needs: [bump-versions, build-cli-binary] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Download bumped release files + uses: actions/download-artifact@v6 + with: + name: bumped-manifests + path: . + + - name: Download CLI binary artifacts + uses: actions/download-artifact@v6 + with: + pattern: cli-binary-* + path: release-artifacts + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.1.38 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Install workspace dependencies + run: bun install --frozen-lockfile + + - name: Smoke release package artifacts + run: bash scripts/test-release-package-artifacts.sh + prepare-release-provenance: name: Commit release provenance - needs: [bump-versions, build-cli-binary] + needs: [bump-versions, build-cli-binary, smoke-release-artifacts] runs-on: ubuntu-latest outputs: release_commit: ${{ steps.prepare.outputs.release_commit }} @@ -396,6 +453,13 @@ jobs: if [ "${{ matrix.settings.binary_name }}" = "tokscale" ]; then chmod +x "packages/${{ matrix.settings.package_dir }}/bin/tokscale" fi + # Ship the apple-fm sidecar dylib next to the binary when present (only + # the arm64-darwin artifact carries it). tokscale dlopen's it at runtime + # from its own directory; "files": ["bin"] already includes it. + if [ -f "artifacts/${{ matrix.settings.package_dir }}/libFoundationModels.dylib" ]; then + cp "artifacts/${{ matrix.settings.package_dir }}/libFoundationModels.dylib" "packages/${{ matrix.settings.package_dir }}/bin/libFoundationModels.dylib" + echo "shipped sidecar libFoundationModels.dylib" + fi ls -la "packages/${{ matrix.settings.package_dir }}/bin" - name: Publish platform package diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml index bdfb51f17..0ef6b58cd 100644 --- a/.github/workflows/test_coverage.yml +++ b/.github/workflows/test_coverage.yml @@ -12,6 +12,8 @@ on: - 'packages/tokscale/package.json' - 'packages/cli-*/package.json' - 'scripts/*.sh' + - 'scripts/*.py' + - '.github/workflows/build-native.yml' - '.github/workflows/publish-cli.yml' - '.github/workflows/test_coverage.yml' pull_request: @@ -24,6 +26,8 @@ on: - 'packages/tokscale/package.json' - 'packages/cli-*/package.json' - 'scripts/*.sh' + - 'scripts/*.py' + - '.github/workflows/build-native.yml' - '.github/workflows/publish-cli.yml' - '.github/workflows/test_coverage.yml' @@ -51,6 +55,7 @@ jobs: bash scripts/test-check-version-coherence.sh bash scripts/test-npm-release-state.sh bash scripts/test-prepare-release-provenance.sh + bash scripts/test-release-workflow-safety.sh - name: Cache cargo registry uses: actions/cache@v5 with: @@ -162,7 +167,13 @@ jobs: git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" git add .github/badges/coverage.svg - git diff --staged --quiet || (git commit -m "ci: update coverage badge [skip ci]" && git push) + git diff --staged --quiet && exit 0 + git commit -m "ci: update coverage badge [skip ci]" + for i in 1 2 3; do + git push && exit 0 + git pull --rebase -X theirs origin main || exit 1 + done + git push - name: Upload coverage artifacts uses: actions/upload-artifact@v6 diff --git a/AGENTS.md b/AGENTS.md index d18de94df..04c57515a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,8 +36,8 @@ This applies to all GitHub-content authoring through the CLI — PR bodies, issu - Before any commit, inspect the effective Git identity (`git config user.name` / `user.email`) and remotes. If the identity does not match the contributor or expected automation account for the current branch, stop and ask for confirmation. - Never commit as worker/agent identities such as `worker1`, `worker2`, `worker3`, or `*@example.invalid`. -- When merging pull requests through `gh`, use squash merge (`gh pr merge --squash ...`) unless the user explicitly requests another merge strategy. -- Before merging, verify the squash commit title is the intended conventional PR title and does not contain worker/agent/internal review jargon. +- When merging pull requests through `gh`, use merge commits (`gh pr merge --merge ...`) unless the user explicitly requests another merge strategy. +- Before merging, verify the merge commit title is the intended conventional PR title and does not contain worker/agent/internal review jargon. ## Commit Message Convention diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..4bd334db6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,132 @@ +# Contributing to Tokscale + +Thanks for your interest in improving Tokscale. This guide covers the development workflow and the most common contribution — adding support for a new AI coding client. For commit-message and pull-request conventions, see [`AGENTS.md`](./AGENTS.md). + +## Development setup + +Tokscale is a Cargo workspace (the `tokscale` CLI and its core library) plus a Next.js web frontend. + +Prerequisites: a stable Rust toolchain (`rustup` recommended) and [Bun](https://bun.sh) for the frontend and package scripts. + +| Task | Command | +| --- | --- | +| Format check | `cargo fmt --all -- --check` | +| Lint (must pass with **no** warnings) | `cargo clippy --locked --workspace --all-features -- -D warnings` | +| Rust tests | `cargo test --workspace --all-features` | +| Build the CLI | `cargo build --release -p tokscale-cli` | +| Frontend dev server | `bun run dev:frontend` | +| Frontend registry contract | `bun run --cwd packages/frontend test -- __tests__/lib/clientRegistry.test.ts` | +| Frontend type check | `bun run --cwd packages/frontend typecheck` | + +CI runs format, Clippy, and Rust tests on Linux, then builds the supported release targets separately. Frontend CI runs Vitest, migration replay, and the type check; it also runs when `crates/tokscale-core/src/clients.rs` or a GitHub CDN asset changes, so cross-registry client checks cannot be skipped by a Rust-only integration PR. A clean local `cargo fmt`, `cargo clippy`, and `cargo test` is the baseline for a reviewable PR. + +## Repository layout + +| Path | Contents | +| --- | --- | +| `crates/tokscale-core` | Client registry, per-client session parsers, scanner, aggregation, and cache | +| `crates/tokscale-cli` | CLI entrypoint, argument parsing, and the terminal UI | +| `packages/frontend` | Next.js web app: public profiles, the submission API, and the frontend client registry | +| `.github/assets` | Client logos, served to the frontend over the GitHub raw CDN | + +## Adding a new client integration + +A "client" is one AI coding tool whose local session logs Tokscale scans, parses, and reports (Claude Code, Codex, Cursor, and so on). A complete integration spans three areas — the Rust core, the CLI, and the web frontend — and the frontend half is required even though the Rust build passes without it. The single most common mistake is registering a client only in Rust: the CLI then scans and submits usage for it, but the server rejects every submission that includes it. See [Registry enforcement](#registry-enforcement) for why. + +Work through the checklist in order. + +### 1. Register the client (Rust core) + +Add an entry to the `define_clients!` invocation in `crates/tokscale-core/src/clients.rs`. Indices must be sequential — use the next unused number (a compile-time assertion enforces this): + +```rust +Pi = 8 => { + id: "pi", // stable id used everywhere: CLI flag, submit payload, frontend + root: PathRoot::Home, // base directory the relative path resolves against + relative: ".pi/agent/sessions", // where the client stores its session logs + pattern: "*.jsonl", // glob for session files under that directory + headless: false, // supports headless / subprocess capture + parse_local: true, // parse files locally (vs. remote-only) + submit_default: true // included in `tokscale submit` by default +}, +``` + +The `id` string is the contract that ties every other layer together. Choose it once and reuse it verbatim. + +### 2. Write the parser (Rust core) + +Add `crates/tokscale-core/src/sessions/.rs` with a function that returns `Vec`, and register the module in `crates/tokscale-core/src/sessions/mod.rs` with `pub mod ;`. Model it on an existing parser of the same shape — JSONL (`pi.rs`), SQLite (`opencode.rs`), or NDJSON (`devin.rs`). Parse defensively: skip malformed rows instead of panicking, and clamp token counts to non-negative values. + +### 3. Wire discovery and dispatch (Rust core) + +Discover the client's session files in `crates/tokscale-core/src/scanner.rs` (follow the pattern used by a similar client), then dispatch parsing inside `parse_all_messages_*` in `crates/tokscale-core/src/lib.rs`, passing the client identity so cache entries record their owner: + +```rust +load_or_parse_source( + message_cache::CacheIdentity::for_client(ClientId::MyClient), + path, + &source_cache, + pricing, + sessions::myclient::parse_myclient_file, +); +``` + +Neither step is compile-enforced: a client that is registered but not wired here builds cleanly and silently produces no data. + +### 4. Wire the CLI + +In `crates/tokscale-cli/src/main.rs`, add the `ClientFilter` variant and its two mappings — the `id` string and the `ClientId` it resolves to — so `--client ` works. In `crates/tokscale-cli/src/tui/client_ui.rs`, add a `ClientUi { display_name, hotkey }` entry with an unused `hotkey`. `CLIENT_UI` is a fixed-size array (`[ClientUi; ClientId::COUNT]`), so the build fails until it has exactly one entry per client. + +### 5. Register the client on the web frontend (required) + +This is the step that is easy to miss and that breaks real usage. Add the `id` to the frontend registry: + +- `packages/frontend/src/lib/types.ts` — add the `id` to `SUPPORTED_CLIENT_TYPES`. **This list gates `POST /api/submit`.** A submission that contains any client id not in this list is rejected in full, so a client missing here cannot submit at all. +- `packages/frontend/src/lib/constants.ts` — add the `id` to all three per-client registries: `SOURCE_DISPLAY_NAMES` (human-readable name), `SOURCE_LOGOS` (logo URL — see step 6), and `SOURCE_COLORS` (a chart color that reads well in both light and dark themes). + +Because those three are `Record`, `tsc` fails to compile once the id is in `SUPPORTED_CLIENT_TYPES` until each has an entry — so if you start with `types.ts`, the type checker guides you through the rest. + +The frontend registry contract test also reads the Rust `define_clients!` list, validates each id through the submit schema, and checks its display name, logo, and color. Frontend CI is explicitly triggered by `clients.rs`, so adding a Rust client without completing this step fails before merge. + +### 6. Add the logo asset + +Add `.github/assets/client-.png` (or `.jpg`) and reference it from `SOURCE_LOGOS`: + +```ts +"": `${GITHUB_CDN_BASE}/client-.png`, +``` + +`SOURCE_LOGOS` may point at an external URL instead, but any `GITHUB_CDN_BASE` reference must resolve to a file that exists in `.github/assets` on `main`, or the frontend renders a broken image. CLI and desktop variants of the same product may share a single asset (as `antigravity` and `antigravity-cli` do). + +The frontend registry contract test checks every GitHub CDN logo against the checked-in asset directory. It is triggered by `.github/assets/**` changes as well as frontend changes. + +### 7. Update user-facing documentation + +Add the client to the supported-client overview and feature list in `README.md`, plus the data-location matrix when a platform-specific path matters. Mirror those user-facing updates in the maintained localized READMEs (`README.ko.md`, `README.ja.md`, and `README.zh-cn.md`). + +### Registry enforcement + +Some registries fail the build when they are incomplete; others fail silently at runtime. Know which is which: + +| Registry / step | Enforced by | If omitted | +| --- | --- | --- | +| `define_clients!` sequential index | Rust compile-time assertion | Build fails | +| `CLIENT_UI` array | Rust fixed-size array | Build fails | +| `SOURCE_DISPLAY_NAMES` / `SOURCE_LOGOS` / `SOURCE_COLORS` | TypeScript `Record` + frontend registry contract test | Type check or CI test fails | +| **`SUPPORTED_CLIENT_TYPES`** | **Frontend registry contract test, triggered by Rust registry changes** | **CI fails before a server-rejected client ships** | +| Scanner + `lib.rs` dispatch | Nothing | Client is defined but scans and reports no data | +| Logo asset file | Frontend registry contract test, triggered by asset changes | CI fails for a missing GitHub CDN asset | + +The scanner/dispatch row is the remaining silent integration gap. Verify it by hand. + +### Verifying the integration + +1. Run `cargo fmt --all -- --check`, `cargo clippy --locked --workspace --all-features -- -D warnings`, and `cargo test --workspace --all-features`. +2. Add a parser unit test with a small fixture in the client's real log format. +3. Run `tokscale --no-spinner --client ` against real local data and confirm the message and token counts look right. +4. Run `bun run --cwd packages/frontend test -- __tests__/lib/clientRegistry.test.ts` and `bun run --cwd packages/frontend typecheck`. +5. Update the supported-client and data-location documentation, including maintained translations. + +## Commit messages and pull requests + +Tokscale uses [Conventional Commits](https://www.conventionalcommits.org), and a PR title becomes its squash-merge commit message. The full rules — allowed types, atomic-commit guidance, and title restrictions — live in [`AGENTS.md`](./AGENTS.md). In short: a new client is a `feat` (for example, `feat(clients): add session parsing`), and titles should describe the change, not internal review or process labels. diff --git a/Cargo.lock b/Cargo.lock index f06cc5ce3..c495b1c68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3430,7 +3430,7 @@ dependencies = [ [[package]] name = "tokscale-cli" -version = "3.1.0" +version = "4.5.3" dependencies = [ "ab_glyph", "aes", @@ -3446,6 +3446,7 @@ dependencies = [ "crossterm 0.28.1", "csv", "dirs", + "fs2", "hostname", "image", "imageproc", @@ -3467,13 +3468,14 @@ dependencies = [ "tokscale-core", "toml", "tracing-subscriber", + "unicode-normalization", "usvg", "uuid", ] [[package]] name = "tokscale-core" -version = "3.1.0" +version = "4.5.3" dependencies = [ "bincode", "chrono", @@ -3695,6 +3697,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 09b5608bf..809b1467b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "3.1.0" +version = "4.5.3" edition = "2021" authors = ["Junho Yeo "] license = "MIT" diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..e1167d48d --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,108 @@ +# Design + +## Source of truth + +- Status: Active +- Last refreshed: 2026-07-12 +- Primary product surfaces: Public user profiles at `/u/[username]`, the profile embed dialog, public README widgets at `/api/embed/[username]/svg`, the global leaderboard at `/leaderboard`, the group directory at `/leaderboard?view=groups`, group detail at `/groups/[slug]`, and the group create/join flows. +- Evidence reviewed: `packages/frontend/src/app/u/[username]/ProfilePageClient.tsx`, `packages/frontend/src/components/profile/`, `packages/frontend/src/lib/embed/`, `packages/frontend/src/app/api/embed/[username]/svg/route.ts`, `packages/frontend/src/app/(main)/leaderboard/`, `packages/frontend/src/app/(main)/groups/`, `packages/frontend/src/lib/leaderboard/`, `packages/frontend/src/lib/groups/`, `packages/frontend/src/components/layout/Navigation.tsx`, `packages/frontend/src/app/globals.css`, actual local API data for the leaderboard and public groups, and the compact content/usage references at `https://cho.sh/ko` and `https://cho.sh/ko/mini/usage`. +- Visual reference captures: `.omx/artifacts/visual-ralph/compact-profile/`, `.omx/artifacts/embed-redesign/`, and the desktop/mobile leaderboard and group baselines under `.omx/artifacts/groups-leaderboard/baseline/`. + +## Brand + +- Personality: Precise, technical, calm, and quietly competitive. +- Trust signals: Exact usage values, transparent time ranges, accessible data labels, visible freshness, and familiar GitHub identity. +- Avoid: Cosmic decoration inside application screens, oversized pill controls, repeated metrics, gratuitous 3D treatment, excessive gradients, dense card stacks, and copying another product's branding. + +## Product goals + +- Goals: Make profiles understandable in one viewport; make global and group rankings immediately scannable; preserve tokens, cost, time, rank, and role at every viewport; make group discovery and membership workflows feel like one service; make every embed template communicate one distinct usage story; and work cleanly from 320px through desktop. +- Non-goals: No database schema, authentication, settings, global landing/footer asset, or `/local` graph redesign in this pass. The leaderboard response may be narrowed to facts its ranking surfaces consume. No new dependency and no pixel-for-pixel clone of the reference sites. +- Success signals: A visitor reaches leaderboard data without crossing a promotional hero; top ranks and active scope scan in seconds; public groups are compact enough to compare; group detail never collides with navigation; mobile ranking rows retain their decisive metrics without horizontal scrolling; and profile, leaderboard, groups, and embeds share one visual grammar. + +## Personas and jobs + +- Primary personas: A developer reviewing their own activity; a visitor comparing public users; a team owner managing a scoped ranking; a member checking standing; and a maintainer debugging submitted usage. +- User jobs: Identify a person or group, understand ranking scope, compare decisive usage metrics, find a member, inspect personal standing, create or join a group, configure a truthful embed, and share the result. +- Key contexts of use: Wide desktop comparison, narrow mobile ranking checks, keyboard-only navigation, authenticated and anonymous group discovery, GitHub README rendering, and datasets with long names, sparse activity, many pages, or missing optional metrics. + +## Information architecture + +- Primary navigation: Existing Tokscale application navigation remains unchanged. `/leaderboard` uses link-based Users/Groups navigation so URLs, history, and server rendering stay authoritative. +- Core routes/screens: `/u/[username]` is the canonical public profile; `/leaderboard` is the compact global ranking; `/leaderboard?view=groups` is the directory and membership entry point; `/groups/[slug]` is the scoped ranking and invite-management view; `/groups/new` and `/groups/join/[token]` are focused single-task forms. +- Content hierarchy: Profiles retain identity → metrics → analysis. Global leaderboard uses title/scope → aggregate facts → range/search/sort → ranking → join instructions. Group directory uses purpose/action → owned/public filter → compact group list. Group detail uses identity/membership → scoped facts → period/search/sort → ranking → invite management when authorized. + +## Design principles + +- Data before decoration: Labels, values, trend, and range context carry the hierarchy. +- One fact, one home: Do not repeat tokens, cost, active time, or sessions across multiple cards. +- Lightest useful surface: Use whitespace and dividers first; reserve bordered panels for the identity overview, charts, Usage details, Token mix, and independently grouped datasets. +- Compact, not cramped: Desktop controls use 28–36px heights; mobile keeps 44–48px coarse-pointer targets without inflating visual chrome. +- Ranking before promotion: Application rankings begin with their title and data; the black-hole marketing hero does not appear on leaderboard or group routes. +- Preserve comparison context: Responsive rankings recompose into compact rows rather than hiding cost, tokens, rank, role, or useful all-time facts. +- Honest group identity: Missing group artwork uses a deterministic text monogram on a restrained surface, never an unrelated decorative gradient. +- Reference, not replica: Adopt the reference's narrow content measure, restrained borders, chart-first composition, and low-noise controls while retaining Tokscale typography, data, and blue accent. +- One widget, one job: Template differences come from information hierarchy and reading density, never costume, decorative metaphor, or renamed identical layouts. +- Tradeoffs: The public profile keeps its purpose-built responsive usage trend and adds an optional inline isometric contribution view using the same scoped calendar as 2D. It does not reuse the heavier `/local` graph container or decorative 3D embed card. Raw totals remain authoritative, the usage trend still defaults to a trailing average, and 2D remains the default contribution view. + +## Visual language + +- Color: Dark zinc-neutral canvas; raised surfaces only slightly lighter; translucent white borders; white/default/muted text with WCAG AA contrast; Tokscale blue for the single primary action and selected data emphasis; provider colors only in chart/legend context. +- Typography: Existing Figtree UI font and JetBrains Mono for code only. Page title 20–24px medium/semibold, section title 16–18px medium, body 14–16px, metadata 12–13px where it is supplementary rather than body copy. Numeric values use tabular figures. +- Spacing/layout rhythm: 4px base; common gaps 8/12/16/20/24px; application canvases max out at 1500px with responsive 16–32px gutters. Keep headline metrics left-packed in compact tracks instead of stretching sparse facts across the canvas. Ranking rows target 56–64px desktop height and an information-complete 76–92px mobile composition. +- Shape/radius/elevation: 8px controls, 12px panels, full radius only for badges/avatars where semantically appropriate. Dark application surfaces use borders, not shadows. +- Motion: Immediate color/background state changes; 120–160ms transform only for pressed controls; honor `prefers-reduced-motion`. +- Imagery/iconography: GitHub avatar with a subtle dark-surface outline at 72px mobile and 80px desktop. Give rank one compact accent-backed emphasis beside identity metadata. Reuse existing 16px application icons and source assets; avoid decorative icon containers. + +## Components + +- Existing components to reuse: `Navigation`, profile components, formatters in `lib/utils`, shared graph palettes/settings, `TabBar`, existing 16px icons, and established server-fetching patterns. +- New/changed components: A shared compact application shell and `ServiceFooter` for profile/ranking routes; compact `LeaderboardViewSelector`, aggregate fact strip, responsive global ranking rows, `GroupDirectory`, deterministic group mark, scoped group overview, responsive group ranking rows, and focused create/join surfaces. Existing profile analytics and embed components remain unchanged in this follow-up. +- Variants and states: Primary/secondary/ghost actions; active/inactive navigation and ranges; current-user and top-three rows; public/private/member/owner/admin group states; loading, empty, search-empty, error, pagination, copied-invite, and submitting states; desktop table and mobile ranking-list compositions. +- Chart contract: Render one stable stacked area per provider/model pair. Order provider groups and their models by raw scoped usage ascending so dominant bands remain on top; sort only the active tooltip rows descending. Use provider-level legend colors, deterministic model shades, 40% fills, 1px monotone boundaries, and no chart animation. +- Contribution contract: Render the complete requested UTC date range, including zero-valued outer days; derive 2D intensity and 3D height from the same token-scoped calendar; expose compact view and palette selectors; show a viewport-clamped daily tooltip on hover/focus; and let click, tap, Enter, or Space update one persistent token, cost, client, and model breakdown. Default that breakdown to the visible range end, preserve it across view and palette changes, retain roving keyboard navigation in both views, and expose a concise screen-reader summary. +- Embed contract: Keep the live preview visually primary, place dense settings in a viewport-contained scroll region, expose only options the selected renderer consumes, and trap/restore focus while the dialog is open. The eight 2D templates share one solid surface, identity header, divider, footer, type scale, and restrained semantic colors while using distinct data hierarchies; decorative gradients, glows, patterns, fake chrome, and metaphor-heavy ornament are excluded. The 3D contribution view remains a supported first-class renderer with its own compatible controls. Desktop uses preview/settings panes; mobile uses one body scroll. +- Embed hierarchy: `Overview` balances identity and the three canonical facts; `Token focus` gives tokens dominant scale; `Readout` is a genuinely terse monospace key/value view; `Contributions` makes the calendar the hero; `Rank focus` centers standing and percentile context; `Activity summary` compares measurable one-year activity signals; `Detailed stats` is the densest two-column fact sheet; `Compact list` is the narrowest scan-first ledger. Do not add template-name overlines, invented system labels, or explanatory slogans inside the SVG. +- Token/component ownership: Additive service tokens live in `src/app/globals.css`; profile composition and variants live in `src/components/profile/`. Shared `/local` graph components are out of scope. + +## Accessibility + +- Target standard: WCAG 2.2 AA. +- Keyboard/focus behavior: Visible focus rings; link semantics for ranking destinations; native buttons, inputs, selects, and checkboxes; complete keyboard access to view, period, sort, pagination, copy, and membership actions; existing chart and embed focus contracts remain intact. +- Contrast/readability: Muted text must remain at least 4.5:1 for normal text; provider color is never the only data label; body text is at least 16px on mobile. +- Screen-reader semantics: Structured headings, `dl` for facts, real table semantics on wide ranking views, equivalent labeled lists on narrow views, `aria-current` on navigation, labeled search and date controls, status/alert regions for async results, and the existing chart/embed semantics. +- Reduced motion and sensory considerations: Disable nonessential transform/animation under reduced motion; preserve labels and values independently of hue. + +## Responsive behavior + +- Supported breakpoints/devices: 320px mobile through wide desktop; primary checks at 390, 768, and 1024+ CSS pixels. The usage chart targets 224px height on mobile and 256px on desktop. +- Layout adaptations: Profile behavior remains as defined. Leaderboard and group pages use a 1500px application shell with 16–32px gutters and no promotional hero. Aggregate facts use compact divider-separated tracks on desktop and a two-column grid on mobile. Global and group rankings render semantic tables where space permits and information-complete linked rows on mobile; no page-level or nested horizontal scroller is required. Group directory cards become compact list-like tiles with bounded descriptions instead of fixed empty height. Identity/actions and control bars wrap into a single column without overlapping the fixed navigation. Create and join forms remain bounded while using full mobile width. +- Touch/hover differences: Coarse pointers receive at least 44px effective targets; chart selection works by tap and keyboard, with a compact detail panel below the chart. Fine pointers receive a clamped, internally scrollable floating tooltip; contribution cells expose the same value on hover and focus. + +## Interaction states + +- Loading: Preserve server rendering and add a profile-shaped route skeleton only if loading behavior is introduced. +- Empty: Keep identity and metrics visible, then explain that usage data has not been submitted yet and point profile owners to the submit command when appropriate. +- Error: Existing route-level not-found behavior remains; interactive copy/share failures use the current toast channel. +- Success: Share confirms copy; embed actions retain their current confirmation behavior. +- Disabled: Native controls expose disabled semantics and reduced contrast without becoming unreadable. +- Offline/slow network, if applicable: Server-rendered profile content remains usable; navigation session enrichment may arrive later without moving the main layout. + +## Content voice + +- Tone: Concise, factual, developer-oriented. +- Terminology: Use “tokens”, “cost”, “active days”, “submissions”, “providers”, “models”, and “devices” consistently. +- Microcopy rules: Sentence case for controls/table headings, punctuation on full explanatory sentences, no emoji, and no ambiguous chart labels such as “all-time history” when only the latest year of daily rows is present. + +## Implementation constraints + +- Framework/styling system: Next.js 16, React 19, and styled-components. No Sass/Tailwind/chart package is introduced. +- Design-token constraints: Add tokens without changing existing global aliases used by landing, leaderboard, settings, groups, or `/local`. +- Performance constraints: Keep chart and contribution geometry derivation memoized; allow normal profiles to retain their model bands, apply a high pathological series cap with an explicit remainder, render only one contribution view at a time, and preserve server data fetching and ISR. Ranking queries and API payloads include only displayed identity, rank, token, cost, time, role, scope, and pagination facts; submission counts and freshness metadata stay on profile-specific surfaces. +- Analytical constraints: Missing calendar dates are zero-valued. Lifetime defaults to a trailing 30-day average and finite ranges to a trailing 7-day average, with daily values available as an explicit display mode. Moving averages never alter raw range totals or stable series ranking. +- Compatibility constraints: Leave auth, database schema, profile APIs, and canonical profile redirects unchanged. The public leaderboard APIs intentionally omit unused submission-count and freshness fields. Do not mutate the shared `GraphContainer` behavior. Preserve all public embed template IDs and query parameters, XML escaping, CSP-compatible standalone SVG output, intrinsic template widths, and the classic fallback for invalid or omitted templates. +- Test/screenshot expectations: Preserve existing profile/embed coverage; add focused tests for view-link filter preservation and responsive ranking/group presentation helpers; run frontend tests, lint, typecheck, and build; capture `/leaderboard`, `/leaderboard?view=groups`, and a populated public `/groups/[slug]` with actual API data at 1440×1100 and 390×844; exercise search, period, sort, view, and primary group navigation; persist the visual verdict under `.omx/state/groups-leaderboard/ralph-progress.json` with a pass target of 90. + +## Open questions + +- [ ] Decide in a future pass whether the compact service language should extend to settings, navigation, and the decorative global footer; owner: product/design; impact: site-wide shell consistency, deliberately excluded from this follow-up. diff --git a/README.ja.md b/README.ja.md index 491533791..58621e915 100644 --- a/README.ja.md +++ b/README.ja.md @@ -15,7 +15,7 @@ > > | [GitHub Follow](https://github.com/junhoyeo) | GitHubで[@junhoyeo](https://github.com/junhoyeo)をフォローして、他のプロジェクトもチェックしてください。AI、インフラ、その他様々な分野で開発しています。 | > | :-----| :----- | -> [Discord link](https://discord.gg/h6DUGWdBbm) | [Discord](https://discord.gg/h6DUGWdBbm)に参加しよう — ���界最高のバイバーたちと一緒に。 | +> [Discord link](https://discord.gg/h6DUGWdBbm) | [Discord](https://discord.gg/h6DUGWdBbm)に参加しよう — 世界最高のバイバーたちと一緒に。 |
@@ -52,39 +52,56 @@ **Tokscale**は以下のプラットフォームからのトークン消費を監視・分析するのに役立ちます: -| ロゴ | クライアント | データ場所 | サポート | -|------|----------|---------------|-----------| -| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+、`opencode-stable.db` など全チャンネル対応) または `~/.local/share/opencode/storage/message/` | ✅ 対応 | -| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` | ✅ 対応 | -| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ レガシー: `.clawdbot`, `.moltbot`, `.moldbot`) | ✅ 対応 | -| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | ✅ 対応 | -| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | ✅ 対応 | -| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db`(フォールバック: `~/.hermes/state.db`) | ✅ 対応 | -| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json`(フォールバック: `~/.gemini/tmp/*/chats/*.json`) | ✅ 対応 | -| Cursor | [Cursor IDE](https://cursor.com/) | `~/.config/tokscale/cursor-cache/`経由でAPI同期 | ✅ 対応 | -| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | ✅ 対応 | -| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/` (+ `manicode-dev`、`manicode-staging`; `CODEBUFF_DATA_DIR` でオーバーライド可能) | ✅ 対応 | -| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | ✅ 対応 | -| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | ✅ 対応 | -| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | `~/.kimi/sessions/` | ✅ 対応 | -| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | ✅ 対応 | -| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | ✅ 対応 | -| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | ✅ 対応 | -| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | ✅ 対応 | -| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | ✅ 対応 | -| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json`(プロジェクトレジストリ。フォールバック: `~/.local/share/crush/projects.json`) | ✅ 対応 | -| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db` (+ macOS Application Support、レガシー Block/goose パス; `GOOSE_PATH_ROOT` でオーバーライド可能) | ✅ 対応 | -| Antigravity | [Google Antigravity](https://antigravity.google/) | `tokscale antigravity sync` で `~/.config/tokscale/antigravity-cache/sessions/*.jsonl` にキャッシュ(ローカル言語サーバ RPC を使用) | ✅ 対応 | -| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo)(国際版) | `tokscale trae sync` で `~/.config/tokscale/trae-cache/sessions/*.json` にキャッシュ(公式 API のアカウント単位使用量) | ✅ 対応 | -| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db`(macOS: `~/Library/Application Support/Zed/threads/threads.db`; Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`; ホスティング済み Zed モデル専用、外部 ACP エージェントは対象外) | ✅ 対応 | -| Kiro | Kiro | `~/.kiro/sessions/cli/*.json`(+ `*.jsonl`)と `~/.local/share/kiro-cli/data.sqlite3`(macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`) | ✅ 対応 | -| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/`(`GJC_CODING_AGENT_DIR`、`GJC_CONFIG_DIR`、`PI_CONFIG_DIR` でオーバーライド可能;Linux/macOS では `$XDG_DATA_HOME/gjc/sessions/` も解決) | ✅ 対応 | -| Synthetic | [Synthetic](https://synthetic.new/) | `hf:`モデルや`synthetic`プロバイダを検出して他ソースから再帰属(+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | ✅ 対応 | +| ロゴ | クライアント | データ場所 | +|------|----------|---------------| +| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+、`opencode-stable.db` など全チャンネル対応) または `~/.local/share/opencode/storage/message/` | +| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` および `~/.claude/transcripts/` | +| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ レガシー: `.clawdbot`, `.moltbot`, `.moldbot`) | +| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | +| Sakana Fugu | [Sakana Fugu](https://sakana.ai/fugu/) | Codex 経由で追跡 — `~/.codex/sessions/*.jsonl` (`model_provider: sakana`) | +| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | +| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db` および `$HERMES_HOME/profiles/*/state.db`(フォールバック: `~/.hermes/...`) | +| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json`(フォールバック: `~/.gemini/tmp/*/chats/*.json`) | +| Cursor | [Cursor IDE](https://cursor.com/) | Cursor API のエクスポートを `~/.config/tokscale/cursor-cache/usage*.csv` にキャッシュ(`~/.cursor` ではない) | +| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | +| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/` (+ `manicode-dev`、`manicode-staging`; `CODEBUFF_DATA_DIR` でオーバーライド可能) | +| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | +| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | +| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) / [Kimi Code](https://github.com/MoonshotAI/kimi-code) | kimi-cli: `~/.kimi/sessions/` kimi-code: `~/.kimi-code/sessions/` (`KIMI_CODE_HOME` でオーバーライド可能) | +| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | +| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | +| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | +| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | +| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | +| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json`(プロジェクトレジストリ。フォールバック: `~/.local/share/crush/projects.json`) | +| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db` (+ macOS Application Support、レガシー Block/goose パス; `GOOSE_PATH_ROOT` でオーバーライド可能) | +| Antigravity | [Google Antigravity](https://antigravity.google/) | `tokscale antigravity sync` で `~/.config/tokscale/antigravity-cache/sessions/*.jsonl` にキャッシュ(ローカル言語サーバ RPC を使用) | +| Antigravity CLI | [Antigravity CLI](https://antigravity.google/) | `~/.gemini/antigravity-cli/conversations/*.db`(Gemini ホームは `GEMINI_CLI_HOME` でオーバーライド可能;ローカル SQLite を直接読み取るため `antigravity sync` は不要) | +| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo)(国際版) | `tokscale trae sync` で `~/.config/tokscale/trae-cache/sessions/*.json` にキャッシュ(公式 API のアカウント単位使用量) | +| Warp | [Warp](https://www.warp.dev/) / Oz | `tokscale warp sync` で `~/.config/tokscale/warp-cache/usage.json` にキャッシュ(集計リクエスト数と使用金額のみ。トークントランスクリプトは含まない) | +| Grok Build | Grok Build | `$GROK_HOME/sessions/*/*/updates.jsonl`(フォールバック: `~/.grok/sessions/*/*/updates.jsonl`) | +| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db`(macOS: `~/Library/Application Support/Zed/threads/threads.db`; Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`; ホスティング済み Zed モデル専用、外部 ACP エージェントは対象外) | +| Kiro | Kiro | `~/.kiro/sessions/cli/*.json`(+ `*.jsonl`)、`~/.local/share/kiro-cli/data.sqlite3`(macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`)、および Kiro IDE の globalStorage スナップショット(`Kiro/User/globalStorage/kiro.kiroagent`; macOS は Application Support、Linux は `~/.config/Kiro`、Windows は `%APPDATA%\Kiro`) | +| Cline | [Cline](https://github.com/cline/cline) | VS Code globalStorage のタスクディレクトリ(Linux: `~/.config/Code/...`; macOS: `~/Library/Application Support/Code/...`; Windows: `%APPDATA%\Code\...`; サーバー: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/`) | +| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/`(`GJC_CODING_AGENT_DIR`、`GJC_CONFIG_DIR`、`PI_CONFIG_DIR` でオーバーライド可能;Linux/macOS では `$XDG_DATA_HOME/gjc/sessions/` も解決) | +| Jcode | [Jcode](https://github.com/1jehuang/jcode) | `~/.jcode/sessions/session_*.json` + `session_*.journal.jsonl` サイドカー(`JCODE_HOME` で上書き可) | +| MiMo Code | [MiMo Code](https://github.com/XiaomiMiMo/MiMo-Code) | `~/.local/share/mimocode/mimocode.db`(XDG データディレクトリ;SQLite) | +| Junie | [Junie](https://www.jetbrains.com/junie/) | `~/.junie/sessions/*/events.jsonl` | +| Command Code | [Command Code](https://github.com/CommandCodeAI/command-code) | `~/.commandcode/projects/**/*.jsonl`(トークン使用量はトランスクリプトから約4文字/トークンで推定;ディスクには永続化されない) | +| ZCode | [ZCode](https://zcode.z.ai/) | `~/.zcode/cli/db/db.sqlite`(v2 使用量データベース)および `~/.zcode/projects/**/*.jsonl`(従来の記録) | +| OpenCodeReview | [OpenCodeReview](https://github.com/alibaba/open-code-review) | `~/.opencodereview/sessions/**/*.jsonl` | +| CodeBuddy | [CodeBuddy](https://www.codebuddy.cn/docs/cli/overview)(CLI・IDE・VS Code プラグイン) | `~/.codebuddy/projects/**/*.jsonl` + 拡張機能ログ | +| WorkBuddy | WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` + SQLite フォールバック | +| Devin CLI | [Devin CLI](https://devin.ai/) | `~/.local/share/devin/cli/sessions.db`(SQLite) | +| Devin Desktop | [Devin Desktop](https://devin.ai/) | ACP イベント:macOS `~/Library/Application Support/Devin/User/acp-events/`、Linux `~/.config/Devin/User/acp-events/`、Windows `%APPDATA%\Devin\User\acp-events\` | +| Synthetic | [Synthetic](https://synthetic.new/) | `hf:`モデルや`synthetic`プロバイダを検出して他ソースから再帰属(+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | [🚅 LiteLLMの価格データ](https://github.com/BerriAI/litellm)を使用してリアルタイム価格計算を提供し、階層型価格モデルとキャッシュトークン割引をサポートしています。 ### なぜ「Tokscale」? +[![Tokscale](./.github/assets/hero.png)](https://tokscale.ai) + このプロジェクトは **[カルダシェフ・スケール(Kardashev Scale)](https://ja.wikipedia.org/wiki/%E3%82%AB%E3%83%AB%E3%83%80%E3%82%B7%E3%82%A7%E3%83%95%E3%83%BB%E3%82%B9%E3%82%B1%E3%83%BC%E3%83%AB)** に触発されています。これは天体物理学者ニコライ・カルダシェフがエネルギー消費量に基づいて文明の技術的発展レベルを測定するために提案した方法です。タイプI文明は惑星上で利用可能なすべてのエネルギーを活用し、タイプIIは恒星の全出力を捕捉し、タイプIIIは銀河全体のエネルギーを支配します。 AI支援開発の時代において、**トークンは新しいエネルギー**です。トークンは私たちの思考力を動かし、生産性を高め、創造的な成果を駆動します。カルダシェフ・スケールが宇宙規模でエネルギー消費を追跡するように、Tokscaleは AI増強開発のランクを上げながらトークン消費を測定します。カジュアルユーザーでも毎日数百万のトークンを消費する人でも、Tokscaleは惑星級開発者から銀河級コードアーキテクトへの旅を視覚化するのに役立ちます。 @@ -105,10 +122,15 @@ AI支援開発の時代において、**トークンは新しいエネルギー* - [プラットフォーム別フィルタリング](#プラットフォーム別フィルタリング) - [日付フィルタリング](#日付フィルタリング) - [価格検索](#価格検索) + - [カスタム価格オーバーライド](#カスタム価格オーバーライド) - [ソーシャルプラットフォームコマンド](#ソーシャルプラットフォームコマンド) + - [Autosubmit](#autosubmit) - [Cursor IDEコマンド](#cursor-ideコマンド) - [Antigravity コマンド](#antigravity-コマンド) - [Trae コマンド](#trae-コマンド) + - [Warp/Oz コマンド](#warpoz-コマンド) + - [タスク別レポート](#タスク別レポート) + - [サブスクリプション使用量](#サブスクリプション使用量) - [出力例](#出力例--lightバージョン) - [設定](#設定) - [環境変数](#環境変数) @@ -143,15 +165,16 @@ AI支援開発の時代において、**トークンは新しいエネルギー* - **インタラクティブTUIモード** - Ratatuiによる美しいターミナルUI(デフォルトモード) - 6つのインタラクティブビュー:概要、モデル、日別、時間別、統計、エージェント(オプションの Minutely ビューを `minutelyTabEnabled` でオプトイン可能) - キーボード&マウスナビゲーション - - 9色テーマのGitHubスタイル貢献グラフ + - 設定可能なカラーテーマのGitHubスタイル貢献グラフ - リアルタイムフィルタリングとソート - ゼロフリッカーレンダリング -- **マルチプラットフォームサポート** - OpenCode、Claude Code、Codex CLI、Copilot CLI、Cursor IDE、Gemini CLI、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi CLI、Qwen CLI、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Zed、Kiro、Trae、Gajae-Code、Synthetic全体の使用量追跡 +- **マルチプラットフォームサポート** - OpenCode、Claude Code、Codex CLI、Copilot CLI、Cursor IDE、Gemini CLI、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi CLI、Qwen CLI、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Antigravity CLI、Zed、Kiro、Trae、Warp/Oz、Cline、Gajae-Code、Grok Build、Jcode、MiMo Code、Command Code、Junie、ZCode、OpenCodeReview、CodeBuddy、WorkBuddy、Devin CLI、Devin Desktop、Synthetic全体の使用量追跡 - **リアルタイム価格** - 1時間ディスクキャッシュ付きでLiteLLMから現在の価格を取得;OpenRouter自動フォールバックと新規モデル向けCursor価格サポート - **詳細な内訳** - 入力、出力、キャッシュ読み書き、推論トークン追跡 - **ネイティブRustコア** - 10倍高速な処理のため、すべての解析と集計をRustで実行 - **Web可視化** - 2Dと3Dビューのインタラクティブ貢献グラフ - **柔軟なフィルタリング** - プラットフォーム、日付範囲、年別フィルタリング +- **タスク別レポート** - マルチバックエンド対応(Apple FM、Claude、Codex、Gemini、Kiro)の LLM によるセッション要約とタスクグルーピング - **JSONエクスポート** - 外部可視化ツール用のデータ生成 - **ソーシャルプラットフォーム** - 使用量の共有、リーダーボード競争、公開プロフィール閲覧 @@ -226,6 +249,7 @@ tokscale # 特定のタブでTUIを起動 tokscale models # モデルタブ tokscale monthly # 日別ビュー(日別内訳を表示) +tokscale hourly # 時間別タブ # レガシーCLIテーブル出力を使用 tokscale --light @@ -248,7 +272,7 @@ tokscale models --json > report.json # ファイルに保存 インタラクティブTUIモードは以下を提供します: -- **6つのビュー**: 概要(チャート + トップモデル)、モデル、日別、時間別、統計(貢献グラフ)、エージェント +- **8つのビュー**: 概要(チャート + トップモデル)、Usage(サブスクリプションクォータ)、モデル、日別、時間別、統計(貢献グラフ)、エージェント。分単位の Minutely ビューはデフォルトで非表示で、`settings.json` の `minutelyTabEnabled` で有効化できます — [設定](#設定)を参照 - **キーボードナビゲーション**: - `←/→/Tab/BackTab`: ビュー切り替え - `↑/↓` または `Home/End`: リスト操作 @@ -257,16 +281,16 @@ tokscale models --json > report.json # ファイルに保存 - `c/d/t`: コスト/日付/トークンでソート - `j`: 今日にジャンプ - `s`: ソース選択ダイアログを開く - - `g`: グループ基準選択ダイアログを開く(モデル、クライアント+モデル、クライアント+プロバイダー+モデル) + - `g`: グループ基準選択ダイアログを開く(モデル、クライアント+モデル、クライアント+プロバイダー+モデル、ワークスペース+モデル、セッション+モデル、クライアント+セッション+モデル) - `h`: 日別/時間別のチャート粒度を切り替え(Overview タブ) - `v`: テーブル/プロフィールビューを切り替え(Hourly タブ) - `y`: 選択行をクリップボードにコピー - - `p`: 9色テーマを循環 + - `p`: カラーテーマを循環 - `r`: データを更新; `Shift+R` で自動更新の切り替え; `+`/`-` で間隔調整 - `e`: JSONにエクスポート - `q` または `Ctrl+C`: 終了 - **マウスサポート**: タブ、ボタン、フィルターをクリック -- **テーマ**: Green、Halloween、Teal、Blue、Pink、Purple、Orange、Monochrome、YlGnBu +- **テーマ**: Green、Halloween、Teal、Blue、Pink、Purple、Orange、Monochrome、YlGnBu、Graphite、Lagoon、Dusk - **設定の永続化**: 設定は`~/.config/tokscale/settings.json`に保存([設定](#設定)を参照) ### グループ基準戦略 @@ -278,6 +302,9 @@ TUIで`g`を押すか、`--light`/`--json`モードで`--group-by`を使用し | **モデル** | `--group-by model` | ✅ | モデルごとに1行 — すべてのクライアントとプロバイダーを統合 | | **クライアント + モデル** | `--group-by client,model` | | クライアント-モデルペアごとに1行 | | **クライアント + プロバイダー + モデル** | `--group-by client,provider,model` | | 最も詳細 — 統合なし | +| **ワークスペース + モデル** | `--group-by workspace,model` | | ローカル使用量をワークスペースキー、次にモデルでグループ化 | +| **セッション + モデル** | `--group-by session,model` | | `session_id` とモデルごとに1行 — 特定のエージェント CLI セッションにコストを帰属 | +| **クライアント + セッション + モデル** | `--group-by client,session,model` | | クライアント・セッション・モデルごとに1行 — `session_id` で結合するマルチエージェントランナーに便利 | **`--group-by model`**(最も統合) @@ -301,6 +328,33 @@ TUIで`g`を押すか、`--light`/`--json`モードで`--group-by`を使用し | OpenCode | anthropic | claude-opus-4-5 | $168 | | Claude | anthropic | claude-opus-4-5 | $970 | +**`--group-by session,model`**(セッション単位のコスト帰属) + +`tokscale models --json --group-by session,model` は `(session_id, model)` ごとに1エントリを出力します。各エントリにはトップレベルの `sessionId` フィールドが含まれるため、ダウンストリームツール(例: マルチエージェント IDE)はコストデータを特定のエージェント CLI セッションに結合できます: + +```json +{ + "groupBy": "session,model", + "entries": [ + { + "sessionId": "019e1e27-af49-7cd1-89b7-7bad1c3f3be2", + "client": "codex", + "provider": "openai", + "model": "gpt-5", + "input": 25251, + "output": 47, + "cacheRead": 1920, + "cacheWrite": 0, + "reasoning": 40, + "messageCount": 12, + "cost": 0.0123 + } + ] +} +``` + +すべての行にクライアント名も必要な場合は `--group-by client,session,model` を使用してください(20以上の対応 CLI 全体を一度に1スポーンで処理)。 + ### プラットフォーム別フィルタリング `--client`(短縮形 `-c`)でレポートを 1 つ以上のクライアントに絞り込めます。フラグは繰り返し可能で、カンマ区切りの値にも対応し、すべてのレポートコマンドで利用できます: @@ -325,9 +379,9 @@ tokscale --client synthetic tokscale --client opencode,claude --week --json ``` -利用可能な値: `opencode`, `claude`, `codex`, `copilot`, `gemini`, `cursor`, `amp`, `codebuff`, `droid`, `openclaw`, `hermes`, `pi`, `kimi`, `qwen`, `roocode`, `kilocode`, `kilo`, `mux`, `crush`, `goose`, `antigravity`, `zed`, `kiro`, `trae`, `gjc`, `synthetic`。 +利用可能な値: `opencode`, `claude`, `codex`, `copilot`, `gemini`, `cursor`, `amp`, `codebuff`, `droid`, `openclaw`, `hermes`, `pi`, `kimi`, `qwen`, `roocode`, `kilocode`, `kilo`, `mux`, `crush`, `goose`, `antigravity`, `antigravity-cli`, `zed`, `kiro`, `trae`, `warp`, `cline`, `gjc`, `grok`, `jcode`, `micode`, `commandcode`, `junie`, `zcode`, `synthetic`。 -> **非推奨のお知らせ**: 既存の単一クライアントフラグ(`--opencode`、`--claude`、`--codex` など)は後方互換性のため引き続き動作しますが、`--help` から非表示となり、次のメジャーリリースで削除予定です。可能な限り `--client` への移行を推奨します。インタラクティブな端末で旧フラグを使用すると 1 行の警告が表示されます。 +> **破壊的変更 (v4.0.0)**: クライアント単位のブール型フラグ(`--opencode`、`--claude`、`--codex` など)は削除され、現在はエラーになります。代わりに正規の `--client`/`-c` フラグを使用してください — 例: `tokscale --client opencode,claude`。 ### 日付フィルタリング @@ -336,6 +390,7 @@ tokscale --client opencode,claude --week --json ```bash # クイック日付ショートカット tokscale --today # 今日のみ +tokscale --yesterday # 昨日のみ tokscale --week # 過去7日間 tokscale --month # 今月 @@ -366,19 +421,55 @@ tokscale pricing "grok-code" # 特定のプロバイダーソースを強制 tokscale pricing "grok-code" --provider openrouter tokscale pricing "claude-3-5-sonnet" --provider litellm + +# カスタム価格オーバーライドを確認 +tokscale pricing list-overrides ``` **検索戦略:** 価格検索は多段階の解決戦略を使用します: -1. **完全一致** - LiteLLM/OpenRouterデータベースでの直接検索 -2. **エイリアス解決** - 親しみやすい名前を解決(例:`big-pickle` → `glm-4.7`) -3. **ティアサフィックス除去** - 品質ティアを削除(`gpt-5.2-xhigh` → `gpt-5.2`) -4. **バージョン正規化** - バージョン形式を処理(`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) -5. **プロバイダープレフィックスマッチング** - 一般的なプレフィックスを試行(`anthropic/`、`openai/`など) -6. **Cursorモデル価格** - LiteLLM/OpenRouterにまだ存在しないモデルのハードコード価格(例:`gpt-5.3-codex`) -7. **ファジーマッチング** - 部分モデル名の単語境界マッチング +1. **カスタム価格オーバーライド** - `~/.config/tokscale/custom-pricing.json` のユーザー定義エントリの完全一致 +2. **完全一致** - LiteLLM/OpenRouterデータベースでの直接検索 +3. **エイリアス解決** - 親しみやすい名前を解決(例:`big-pickle` → `glm-4.7`) +4. **ティアサフィックス除去** - 品質ティアを削除(`gpt-5.2-xhigh` → `gpt-5.2`) +5. **バージョン正規化** - バージョン形式を処理(`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) +6. **プロバイダープレフィックスマッチング** - 一般的なプレフィックスを試行(`anthropic/`、`openai/`など) +7. **Cursorモデル価格** - LiteLLM/OpenRouterにまだ存在しないモデルのハードコード価格(例:`gpt-5.3-codex`) +8. **ファジーマッチング** - 部分モデル名の単語境界マッチング + +### カスタム価格オーバーライド + +アップストリームの価格データベースがまだ正しくカバーしていないモデル ID の価格を上書きするには、Tokscale の設定ディレクトリ(デフォルトでは macOS/Linux の `~/.config/tokscale/custom-pricing.json`;`TOKSCALE_CONFIG_DIR` を設定した場合は同じく解決されるディレクトリ)に `custom-pricing.json` を作成します。 + +```json +{ + "$schema": "https://tokscale.ai/custom-pricing.schema.json", + "models": { + "accounts/fireworks/routers/kimi-k2p6-turbo": { + "input_cost_per_million_tokens": 2.00, + "output_cost_per_million_tokens": 8.00, + "cache_read_input_token_cost_per_million_tokens": 0.30, + "source": "https://docs.fireworks.ai/serverless/pricing", + "notes": "Fireworks Kimi K2.6 Turbo (preview)" + }, + "accounts/fireworks/models/kimi-k2p6": { + "input_cost_per_million_tokens": 0.95, + "output_cost_per_million_tokens": 4.00, + "cache_read_input_token_cost_per_million_tokens": 0.16 + }, + "kimi-k2p6-turbo": { + "input_cost_per_million_tokens": 2.00, + "output_cost_per_million_tokens": 8.00 + } + } +} +``` + +オーバーライド価格は、ほとんどの API プロバイダーが価格を公開する方法と同じく、100万トークンあたりのドルで入力します;Tokscale は内部でこれをトークンあたりのレートに変換します。`input_cost_per_million_tokens` または `output_cost_per_million_tokens` の少なくとも一方が存在し正の値である必要があり、キャッシュ読み取り/キャッシュ作成フィールドは任意です。コピー/ペーストの互換性のため、`input_cost_per_token`、`output_cost_per_token`、`cache_read_input_token_cost` などの LiteLLM スタイルのトークンあたりフィールド名も受け付けますが、ユーザー向けには100万トークンあたりの名前を推奨します。ティアやキャッシュ価格を省略するにはフィールドを残さないでください;負の値や非有限な値は無効として扱われ、タイプミスが集計を密かに変えないようにモデルエントリ全体がスキップされます。任意の `source` および `notes` フィールドは Tokscale には無視され、自分の記録用に使用できます。 + +オーバーライドは完全一致のみで、大文字小文字を区別しません。Tokscale はまず生のモデル ID をチェックし、次に既存の synthetic な `/models/` 正規化、その後オーバーライドが一致しなければ LiteLLM、OpenRouter、Cursor 価格、ファジーマッチングへフォールスルーします。生の完全一致は正規化された完全一致より優先されるため、`accounts/fireworks/routers/kimi-k2p6-turbo` で特定のゲートウェイ固有モデルを上書きしつつ、`kimi-k2p6-turbo` で正規化された `/models/` パスをカバーできます。オーバーライドは起動時に一度だけ読み込まれます;ファイルを編集したらコマンドを再起動してください。これは、アップストリームの LiteLLM 価格更新を待つ間、誤ったモデル価格のバグを修正するための推奨ローカル対処法です。 **プロバイダー優先順位:** @@ -400,12 +491,29 @@ tokscale pricing "claude-3-5-sonnet" --provider litellm # Tokscaleにログイン(GitHub認証用にブラウザを開く) tokscale login +# ブラウザ認証なしで既存の Tokscale API トークンを保存 +tokscale login --token tt_xxx + # ログイン中のユーザーを確認 tokscale whoami +# 保存済みの API トークンを QR コードとして表示(別デバイスへの共有に便利) +# {"token":"tt_xxx","username":"..."} をエンコード — 任意の QR リーダーでスキャン +tokscale qr + # 使用量データをリーダーボードに送信 tokscale submit +# 認証情報を書き込まずに CI/ヘッドレス環境で送信 +# 優先順位: TOKSCALE_API_TOKEN 環境変数 > 保存済み認証情報ファイル(~/.config/tokscale/credentials.json)。 +# 環境変数が設定されている場合、その実行では保存済みファイルは無視されます。 +TOKSCALE_API_TOKEN=tt_xxx tokscale submit + +# トークンの失効: リーダーボードサイトの Settings > API Tokens +# (https://tokscale.ai/settings)を開き、該当トークン行の "Revoke" をクリック。 +# 失効は即座に有効になり、以降そのトークンを使ったリクエストは +# HTTP 401 "Invalid API token" を返します。 + # フィルター付きで送信 tokscale submit --client opencode,claude --since 2024-01-01 @@ -418,6 +526,31 @@ tokscale logout CLI Submit +### Autosubmit + +Autosubmit は、通常の `tokscale submit` フローを OS のスケジューラーに登録します。手動でターミナルを実行しなくても、公開プロフィールを最新の状態に保てるので便利です。 + +```bash +# 定期送信を有効化。macOS では launchd、Linux では利用可能な場合は systemd ユーザータイマー +# (フォールバックとして cron)、Windows では Windows タスクスケジューラーを使用します。 +tokscale autosubmit enable --interval 24h + +# submit に渡すのと同じクライアント/日付フィルターをそのまま指定できます。 +tokscale autosubmit enable --interval 2h --client opencode,claude --week + +# 保存済みの設定と直近の実行/エラーを表示。 +tokscale autosubmit status +tokscale autosubmit status --json + +# 保存済みの間隔が経過していなくても、その場で一度だけ実行。 +tokscale autosubmit run --force + +# Autosubmit を無効化し、スケジューラーのエントリを削除。 +tokscale autosubmit disable +``` + +スケジュールされた実行は非対話的です。GitHub 認証やスター確認を求めることはありません。`tokscale login --token tt_xxx` を一度実行するか、スケジューラー環境で `TOKSCALE_API_TOKEN` を設定してください。Tokscale はスケジューラーの状態を `settings.json` に記録し、ログを `~/.config/tokscale/autosubmit/` に書き込み、ロックファイルを使用することで、スケジューラーのティックが重なっても二重送信を防ぎます。 + ### Cursor IDEコマンド Cursor IDEはセッショントークンによる別途認証が必要です(ソーシャルプラットフォームのログインとは異なる): @@ -433,6 +566,9 @@ tokscale cursor status # 保存済みのCursorアカウント一覧 tokscale cursor accounts +# キャッシュされたCursor使用量を手動で更新 +tokscale cursor sync --json + # アクティブアカウントを切り替え(cursor-cache/usage.csvに同期されるアカウント) tokscale cursor switch work @@ -514,6 +650,177 @@ tokscale trae logout --variant solo > **中国版**: 中国版(`trae.com.cn`)は意図的に未対応です。CN バックエンドはセッション単位の使用量クエリ API を公開していません。上流で公式エンドポイントが提供された場合に追加します。 +### Warp/Oz コマンド + +Warp/Oz はローカルのトークントランスクリプトを公開していません。Tokscale は Warp の GraphQL API が返す集計リクエスト数と使用金額カウンターのみを同期し、トークンバケットがゼロの `warp` / `aggregate-requests` 行としてレポートします。 + +```bash +# 認証済み Warp リクエストからコピーした Bearer トークンまたは Cookie ヘッダーを保存 +tokscale warp login + +# 認証情報・キャッシュの状態と診断情報を確認 +tokscale warp status + +# 集計リクエスト数と使用金額を tokscale のローカルキャッシュに同期 +tokscale warp sync + +# 保存済み認証情報を削除。--purge-cache を付けると同期済み使用量も削除 +tokscale warp logout --purge-cache +``` + +**キャッシュ場所**: `~/.config/tokscale/warp-cache/usage.json` + +**仕組み**: `tokscale warp sync` は Warp の認証済み GraphQL API を呼び出し、アカウントおよびワークスペースの集計カウンターを取得します。Tokscale はリクエスト数をメッセージ数として、ベンダー報告の使用金額をコストとして保持しますが、リクエストを合成トークンに変換することはありません。Warp はトークン単位の使用量ではなく集計リクエストカウンターのみを持つため、公開リーダーボード向けの `submit` データからは除外されます。 + +### タスク別レポート + +`report` コマンドは、タスク単位の使用量内訳を生成します。LLM を使って各セッションを短いタイトルとカテゴリに要約し、関連するセッションを高レベルのタスククラスタにまとめることで、トークンがどこに使われたかを俯瞰できます。 + +```bash +# 基本レポート(今日、デフォルトの Apple FM サマライザー) +tokscale report + +# 過去7日間 +tokscale report --week + +# Claude Code をサマライザーバックエンドとして使用 +tokscale report --week --summarizer claude + +# Codex、Gemini、Kiro を使用 +tokscale report --summarizer codex +tokscale report --summarizer gemini +tokscale report --summarizer kiro + +# LLM 要約をスキップ(生データのみ表示) +tokscale report --no-summarize + +# 一から再要約(範囲内のキャッシュ済み要約をリセット) +tokscale report --week --rebuild + +# JSON として出力 +tokscale report --week --json + +# ワークスペースやクライアントでフィルター +tokscale report --workspace my-project --client opencode +``` + +LLM 要約は**デフォルトで有効**になっています(`--no-summarize` でオプトアウト可能)。 + +**サマライザーバックエンド:** + +| バックエンド | コマンド | 備考 | +|---------|---------|-------| +| `apple-fm` | (デフォルト) | ネイティブ Rust FFI 経由のオンデバイス Apple Foundation Models(Python 不要)。ビルド済みの Apple Silicon(macOS arm64)バイナリで有効化されており、Apple Intelligence を有効にした macOS 26 以降で動作します。それ以外(Intel Mac、それ以前の macOS、Linux、Windows)では組み込みの Rust ヒューリスティックに透過的にフォールバックするため、デフォルトはすべてのプラットフォームで動作します。 | +| `claude` | `claude -p` | Claude Code CLI がインストールされ認証済みである必要があります。 | +| `codex` | `codex --quiet` | Codex CLI がインストールされ認証済みである必要があります。 | +| `gemini` | `gemini -p` | Gemini CLI がインストールされ認証済みである必要があります。 | +| `kiro` | `kiro --non-interactive` | Kiro CLI がインストールされ認証済みである必要があります。 | + +**仕組み:** + +1. セッションがスキャンされ、プラットフォームの設定ディレクトリにあるローカルの SQLite wiki データベース(`wiki.db`)に挿入されます(Linux では `~/.config/tokscale/`、macOS では `~/Library/Application Support/tokscale/`) +2. 未要約のセッションがバッチで選択した LLM バックエンドに送られ、それぞれにタイトル・カテゴリ・説明・複雑度が返されます +3. 2 回目の LLM パスで、タイトル付けされたすべてのセッションを 3〜8 個の高レベルなタスククラスタにまとめます(例: "Kiro Auth"、"Tokscale Report"、"System Config") +4. 結果は wiki DB にキャッシュされ、以降の実行では要約済みのセッションをスキップします + +**出力例:** + +``` + Task Group Sess Tokens Cost + ─────────────────────────────────────────────────────────────────────── + Tokscale Development 19 4.2B $22.66 + Add task-attributed report command + Implement wiki DB schema + Fix pricing lookup for new models + System Config 28 2.1B $10.06 + Configure OpenCode workspace settings + Update shell aliases + Kiro Auth 4 890.5M $3.10 + Implement JWT refresh flow +``` + +### サブスクリプション使用量 + +Tokscale は AI プロバイダー横断でリアルタイムのサブスクリプションクォータを取得・表示できます。プランをどれだけ使用したか、いつ上限がリセットされるかを確認できます。 + +```bash +# 検出されたすべてのプロバイダーのサブスクリプション使用量を表示 +tokscale usage + +# JSON として出力(スクリプト用) +tokscale usage --json + +# 軽量なターミナル出力(TUI なし) +tokscale usage --light +``` + +TUI では **Usage** タブに移動するとサブスクリプションデータを確認できます。`[Refresh]` でサブスクリプションクォータを更新できます。キーボードの更新ショートカット `r` も同じ更新パスを使用します。 + +> **注**: サブスクリプションのクォータと残高は**ベンダー報告**です — tokscale は各プロバイダー自身のクォータエンドポイントを呼び出し、そのレスポンスをそのまま表示します。数値はプロバイダーが報告する内容(公式ダッシュボードに表示されるものと同じ)を反映しており、tokscale 独自の使用量追跡とは独立して検証されていません。 + +#### 対応プロバイダー + +| プロバイダー | 認証方法 | メトリクス | セットアップ | +|----------|-------------|---------|-------| +| **Claude** | OAuth(資格情報ファイルまたは macOS Keychain) | Session(5時間)、Weekly、Opus クォータ | `claude` を実行してログイン | +| **Codex**(OpenAI) | OAuth(`~/.config/codex/auth.json`、`~/.codex/auth.json`、または保存済み Tokscale アカウント) | Session、Weekly クォータ | TUI の Usage タブで `[Add Codex]` を使用するか、`codex` を実行してログイン、または `tokscale codex import --name work` で既存の認証をインポート | +| **Z.ai** | API キー(環境変数) | トークン上限、Web 検索 | `ZAI_API_KEY` または `GLM_API_KEY` を設定 | +| **Amp** | API キー(`~/.local/share/amp/secrets.json`) | 無料枠残高、クレジット | `amp` を実行してログイン | +| **GitHub Copilot** | GitHub トークン(keychain または `~/.config/gh/hosts.yml`) | プレミアムインタラクション、チャットクォータ | `gh auth login` を実行 | +| **Grok Build** | OAuth(`~/.grok/auth.json`) | クレジット、サブスクリプションプラン | `grok login` を実行 | +| **Kimi** | OAuth(`~/.kimi/credentials/kimi-code.json`) | Session、Weekly クォータ | `kimi` を実行してログイン | +| **MiniMax** | API キー(環境変数) | モデルごとのプロンプトクォータ | `MINIMAX_API_KEY` または `MINIMAX_API_TOKEN` を設定 | +| **MiniMax Token Plan** | API キー(環境変数) | 期間 + 週間の残量パーセントクォータ(リージョン別: CN minimaxi.com + Global minimax.io) | `MINIMAX_TOKEN_PLAN_CN_KEY` および/または `MINIMAX_TOKEN_PLAN_GLOBAL_KEY` を設定 | +| **Sakana**(Fugu) | セッションクッキー(環境変数またはファイル) — 課金コンソールの HTML スクレイプ、公開 API なし | 5時間、Weekly クォータウィンドウ(プランティアと月額料金をメタデータとして) | `SAKANA_SESSION_COOKIE` を設定([docs/providers/sakana.md](docs/providers/sakana.md) を参照) | + +プロバイダーは自動検出されます — 有効な資格情報を持つものだけが表示されます。プロバイダーが表示されない場合は、ログイン済みか、必要な環境変数が設定されているか確認してください。 + +#### Codex マルチアカウント使用量 + +Tokscale はサブスクリプション使用量表示のために複数の Codex OAuth アカウントを保存できます。TUI の Usage タブでは、保存済みアカウントを 1 つの **Codex** セクションにまとめて表示します。アクティブなアカウントは `*` で示され、非アクティブなアカウントは `[Use]` で選択でき、アカウントの削除は `[Remove]` に続けて `[Confirm]` を使用します。 + +TUI を離れずにアカウントを追加するには、Usage タブで `[Add Codex]` をクリックします。Tokscale は一時的な `CODEX_HOME` で `codex login` を起動し、ログイン出力を Usage タブに表示し、生成された認証を Tokscale の保存済みアカウントストアにインポートしてから使用量を更新します。これによりログインが隔離され、現在の Codex 認証は切り替わりません。保存済みアカウントを実際の Codex 認証ファイルに書き込みたい場合は、そのアカウントの `[Use]` をクリックしてください。 + +スクリプトや手動でのアカウント管理のために、CLI コマンドも引き続き利用できます: + +```bash +# 現在の Codex 認証を名前付きの Tokscale アカウントとして保存 +tokscale codex import --name work + +# 保存済みの Codex アカウント一覧 +tokscale codex accounts +tokscale codex accounts --json + +# アクティブな Codex アカウントを切り替えて Codex の auth.json を書き込む +tokscale codex switch work + +# 保存済みの Codex アカウントの追跡を停止(Tokscale のストアからのみ削除 — +# codex CLI 自身の auth.json/ログインには一切触れません) +tokscale codex remove personal + +# アクティブまたは指定アカウントのサブスクリプション使用量を確認 +tokscale codex status +tokscale codex status --name personal --json +``` + +保存済みの Codex アカウントが存在する場合、`tokscale usage --json` は各 Codex エントリの構造化されたアカウントメタデータを含み、TUI はそれらのエントリを 1 つの Codex グループにまとめて表示します。保存済みアカウントがない場合、Tokscale は現在の Codex 認証検出パス(`CODEX_HOME/auth.json`、`~/.config/codex/auth.json`、`~/.codex/auth.json`、その後 macOS Keychain)にフォールバックします。 + +#### 出力例 + +``` +╭──────────────────────────────────────────────────────────╮ +│ Session 85% left [=========---] resets in 2h 15m │ +│ Weekly 72% left [========----] resets Fri 3pm │ +│ Plan Max 20x │ +╰──────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ Session 40% left [=====-------] resets in 4h 30m │ +│ Weekly 90% left [==========--] resets Mon 12am │ +│ Account user@example.com │ +│ Plan Pro │ +╰──────────────────────────────────────────────────────────╯ +``` + ### 出力例(`--light`バージョン) CLI Light @@ -526,13 +833,25 @@ Tokscaleは設定を`~/.config/tokscale/settings.json`に保存します: { "colorPalette": "blue", "includeUnusedModels": false, - "defaultClients": ["opencode", "claude"] + "defaultClients": ["opencode", "claude"], + "scanner": { + "extraScanPaths": { + "codex": [ + "/Users/me/workspace/project-a/.codex/sessions", + "/Users/me/workspace/project-b/.codex/archived_sessions" + ], + "hermes": [ + "/Users/me/.hermes/profiles/director_planning", + "/Users/me/.hermes/profiles/research/state.db" + ] + } + } } ``` | 設定 | タイプ | デフォルト | 説明 | |---------|------|---------|-------------| -| `colorPalette` | string | `"blue"` | TUIカラーテーマ(green、halloween、teal、blue、pink、purple、orange、monochrome、ylgnbu) | +| `colorPalette` | string | `"blue"` | TUIカラーテーマ(green、halloween、teal、blue、pink、purple、orange、monochrome、ylgnbu、graphite、lagoon、dusk) | | `includeUnusedModels` | boolean | `false` | レポートでゼロトークンのモデルを表示 | | `autoRefreshEnabled` | boolean | `false` | TUIの自動更新を有効化 | | `autoRefreshMs` | number | `60000` | 自動更新間隔(30000-3600000ms) | @@ -540,6 +859,9 @@ Tokscaleは設定を`~/.config/tokscale/settings.json`に保存します: | `defaultClients` | string[] | `[]` | `--client/-c` フラグを渡さない場合に適用されるクライアントフィルター。`--client` と同じ ID を受け付けます(例: `["opencode", "claude", "synthetic"]`)。未知の ID は無視されます。CLI フラグが指定されるとこのリストは完全に無視されます — マージはしません。 | | `light.writeCache` | boolean | `false` | `true` のとき、`tokscale --light` はレンダリング直後に TUI キャッシュを原子的に上書きします。CLI フラグ `--write-cache` / `--no-write-cache` が実行ごとに優先されます。 | | `minutelyTabEnabled` | boolean | `false` | TUI に分単位の Minutely タブを表示し、データ読み込み時に分単位の集計を実行します。分単位の粒度はほとんどのユーザーにとってニッチな診断ビューであり、大規模データセットでは分単位のバケット処理に無視できないコストがかかるため、既定では無効になっています。 | +| `scanner.extraScanPaths` | object | `{}` | Tokscale のデフォルトのホームルート以外にあるセッション向けの、クライアントごとの追加スキャンルート | + +プロジェクトレベルの `.codex` ディレクトリや、インポートした Gemini/OpenClaw 履歴など、恒久的な追加ルートには `scanner.extraScanPaths` を使用してください。Tokscale は `$HERMES_HOME/profiles/*/state.db` 以下の Hermes プロファイルデータベースを自動的に検出します(`HERMES_HOME` が未設定の場合は `~/.hermes/profiles/*/state.db`)。標準外の Hermes プロファイル場所にのみ `scanner.extraScanPaths.hermes` を使用してください。Hermes のエントリは `state.db` を含むプロファイルディレクトリ、または `state.db` ファイルを直接指すことができます。Tokscale はこれらのパスを毎回デフォルトのスキャンルートとマージし、重複するルートを正規パスで重複排除します。 #### Minutely タブの有効化 @@ -560,7 +882,7 @@ Minutely タブはトークン使用量を分単位で表示し、バースト 再生成可能な CLI/TUI/料金/Wrapped キャッシュは `~/.config/tokscale/cache/` 配下に保存されます(`TOKSCALE_CONFIG_DIR` を設定した場合は `${TOKSCALE_CONFIG_DIR}/cache/`)。連携同期アーティファクトは `~/.config/tokscale/antigravity-cache/` や `~/.config/tokscale/trae-cache/` など、クライアントごとのキャッシュルートに保存されます。 - `tui-data-cache.json` — TUI 起動キャッシュ -- `source-message-cache.bin` + `source-message-cache.lock` — ソースメッセージキャッシュとロックファイル +- `source-message-cache-v2/` + `source-message-cache.lock` — シャード化されたソースメッセージキャッシュとロックファイル - `pricing-litellm.json` / `pricing-openrouter.json` — 料金キャッシュ - `opencode-migration.json` — OpenCode 移行記録 - `fonts/`、`images/` — Wrapped アセットキャッシュ @@ -574,14 +896,23 @@ Minutely タブはトークン使用量を分単位で表示し、バースト | 変数 | デフォルト | 説明 | |----------|---------|-------------| | `TOKSCALE_NATIVE_TIMEOUT_MS` | `300000`(5分) | `nativeTimeoutMs` 設定をオーバーライド | +| `TOKSCALE_API_TOKEN` | unset | 非対話的な `submit` および `delete-submitted-data` 実行用の Tokscale 個人 API トークン。Settings > API Tokens から作成するか、`tokscale login --token tt_xxx` でローカルに保存できます。 | +| `TOKSCALE_EXTRA_DIRS` | unset | 一時的な追加セッションルートを `client:/abs/path,client:/abs/path` 形式で指定 | | `TOKSCALE_CONFIG_DIR` | unset | 設定ディレクトリのルート(`settings.json`、`star-cache.json`、`cache/`、`antigravity-cache/`、`trae-cache/` の保存場所)をオーバーライドします。絶対パス推奨;相対パスはプロセス CWD を基準に解決されます。CI サンドボックスや非デフォルトの場所を固定したい場合に便利です。設定されている場合、tokscale は macOS のレガシーパス(`~/Library/Application Support/tokscale/`)にフォールバックしません。 | +| `TOKSCALE_FM_DEBUG` | unset | 設定すると、Apple Foundation Models の診断情報(macOS バージョンゲート、dlopen の dylib パス、ロード/シンボルエラー)を stderr に出力し、オンデバイスの apple-fm が動作した(またはしなかった)理由を説明します。 | ```bash # 例:非常に大きなデータセット用にタイムアウトを増加 TOKSCALE_NATIVE_TIMEOUT_MS=600000 tokscale graph --output data.json + +# 例:一時的な追加スキャンルート +TOKSCALE_EXTRA_DIRS='codex:/Users/me/workspace/project-a/.codex/sessions,gemini:/Users/me/imports/imac/gemini/tmp' tokscale + +# 例:対話的なブラウザログインなしで CI から送信 +TOKSCALE_API_TOKEN=tt_xxx tokscale submit ``` -> **注**: 恒久的な変更には、`~/.config/tokscale/settings.json`で`nativeTimeoutMs`を設定することをお勧めします。環境変数は一時的なオーバーライドやCI/CDに適しています。 +> **注**: 恒久的な追加ルートには、`~/.config/tokscale/settings.json` の `scanner.extraScanPaths` を推奨します。`TOKSCALE_EXTRA_DIRS` は一時的なオーバーライドや CI/CD に適しています。 ### ヘッドレスモード @@ -657,7 +988,7 @@ tokscale sources --json - **インタラクティブツールチップ**: ホバーで詳細な日別内訳を表示 - **日別内訳パネル**: クリックでソース別、モデル別の詳細を確認 - **年別フィルタリング**: 年間を移動 -- **ソースフィルタリング**: プラットフォーム別フィルター(OpenCode、Claude、Codex、Copilot、Cursor、Gemini、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi、Qwen、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Zed、Kiro、Trae、Gajae-Code、Synthetic) +- **ソースフィルタリング**: プラットフォーム別フィルター(OpenCode、Claude、Codex、Copilot、Cursor、Gemini、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi、Qwen、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Antigravity CLI、Zed、Kiro、Trae、Warp、Cline、Gajae-Code、Grok Build、Jcode、MiMo Code、Command Code、Junie、ZCode、Synthetic) - **統計パネル**: 総コスト、トークン、活動日数、連続記録 - **FOUC防止**: Reactハイドレーション前にテーマを適用(フラッシュなし) @@ -691,13 +1022,27 @@ GitHubプロフィールREADMEにTokscaleの公開統計を直接埋め込むこ [![Tokscale Stats](https://tokscale.ai/api/embed//svg)](https://tokscale.ai/u/) ``` -- ``をGitHubユーザー名に置き換えてください -- オプションのクエリパラメータ: - - `theme=light` ライトテーマを使用 - - `sort=tokens`(デフォルト)または`sort=cost` ランキング基準を制御 - - `compact=1` コンパクトレイアウト + コンパクトな数値表記(例:`1.2M`、`$3.4K`) -- 例: - - `https://tokscale.ai/api/embed//svg?theme=light&sort=cost&compact=1` +`` を GitHub ユーザー名に置き換えてください。クエリパラメータを付けない場合は既定の `classic` カードがレンダリングされます。以下のパラメータを追加してデザインをカスタマイズできます。 + +| パラメータ | 値 | 効果 | +| --- | --- | --- | +| `template` | `classic`(デフォルト)· `minimal` · `terminal` · `graph` · `orbit` · `vitals` · `blueprint` · `receipt` | カードデザイン | +| `color` | `blue` · `green` · `teal` · `purple` · `pink` · `orange` · `monochrome` · `halloween` · `YlGnBu` | アクセントカラーと貢献グラフのパレット | +| `theme` | `dark`(デフォルト)· `light` | ライトまたはダークのカード | +| `sort` | `tokens`(デフォルト)· `cost` | ランクを取得するリーダーボード | +| `tokens`, `cost` | `compact` · `full` | 数値フォーマット、個別に設定可能 — `20.9B` か `20,941,000,000` | +| `rank` | `plain`(デフォルト、`#134`)· `percent`(`top 12%`)· `total`(`#134 / 1,174`) | リーダーボードのランクの表示方法 | +| `graph` | `1` で貢献グラフを追加(既定はオフ) | `classic`、`minimal`、`terminal`、`orbit`、`blueprint`、`receipt` でサポート | +| `compact` | `1` でコンパクトレイアウト | `classic` のみ | + +例: + +```md +![](https://tokscale.ai/api/embed//svg?template=minimal&color=purple&graph=1) +![](https://tokscale.ai/api/embed//svg?template=orbit&color=pink&rank=percent) +![](https://tokscale.ai/api/embed//svg?template=terminal&color=green&theme=light) +![](https://tokscale.ai/api/embed//svg?template=receipt&color=YlGnBu&graph=1) +``` ### GitHubプロフィールバッジ @@ -721,7 +1066,7 @@ shields.ioスタイルのよりコンパクトなバッジも使用できます ### はじめに -1. **ログイン** - `tokscale login`を実行してGitHubで認証 +1. **ログイン** - `tokscale login`を実行してGitHubで認証するか、CI/ヘッドレス用途では Settings で API トークンを作成 2. **送信** - `tokscale submit`を実行して使用量データをアップロード 3. **表示** - Webプラットフォームを訪問してプロフィールとリーダーボードを確認 @@ -916,16 +1261,18 @@ cd packages/core && bun run bench ### ネイティブモジュールターゲット -| プラットフォーム | アーキテクチャ | ステータス | -|----------|--------------|--------| -| macOS | x86_64 | ✅ サポート | -| macOS | aarch64(Apple Silicon) | ✅ サポート | -| Linux | x86_64(glibc) | ✅ サポート | -| Linux | aarch64(glibc) | ✅ サポート | -| Linux | x86_64(musl) | ✅ サポート | -| Linux | aarch64(musl) | ✅ サポート | -| Windows | x86_64 | ✅ サポート | -| Windows | aarch64 | ✅ サポート | +| プラットフォーム | アーキテクチャ | +|----------|--------------| +| macOS | x86_64 | +| macOS | aarch64(Apple Silicon) | +| Linux | x86_64(glibc) | +| Linux | aarch64(glibc) | +| Linux | x86_64(musl) | +| Linux | aarch64(musl) | +| Windows | x86_64 | +| Windows | aarch64 | + +Linux では、ランチャーが glibc と musl を自動検出します(`process.report`、`/lib/ld-musl-*.so.1` の musl 動的ローダー、`ldd` を使用)。検出が誤ったフレーバーを選んでしまう場合(例: 最小構成のコンテナ)は、`TOKSCALE_LIBC=musl`(または `TOKSCALE_LIBC=gnu`)を設定して強制してください。 ### Windowsサポート @@ -954,21 +1301,34 @@ AIコーディングツールはクロスプラットフォームの場所にセ | Hermes Agent | `~/.hermes/` | `%USERPROFILE%\.hermes\` | `HERMES_HOME`環境変数で設定可能([ソース](https://github.com/NousResearch/hermes-agent/blob/main/website/docs/developer-guide/session-storage.md)) | | Gemini CLI | `~/.gemini/` | `%USERPROFILE%\.gemini\` | `GEMINI_CLI_HOME`環境変数で設定可能 | | Amp | `~/.local/share/amp/` | `%USERPROFILE%\.local\share\amp\` | OpenCodeと同様に`xdg-basedir`を使用 | -| Cursor | API同期 | API同期 | APIでデータを取得、`%USERPROFILE%\.config\tokscale\cursor-cache\`にキャッシュ | +| Cursor | API同期 | API同期 | Cursor API から取得したデータを `usage*.csv` としてキャッシュ;ローカルの `~/.cursor` セッションデータは解析しない | | Droid | `~/.factory/` | `%USERPROFILE%\.factory\` | すべてのプラットフォームで同じパス | | Pi | `~/.pi/` and `~/.omp/` | `%USERPROFILE%\.pi\` and `%USERPROFILE%\.omp\` | すべてのプラットフォームで同じパス(Pi と [Oh My Pi](https://github.com/can1357/oh-my-pi) の両方をサポート) | | Kimi CLI | `~/.kimi/` | `%USERPROFILE%\.kimi\` | すべてのプラットフォームで同じパス | +| Kimi Code | `~/.kimi-code/` | `%USERPROFILE%\.kimi-code\` | すべてのプラットフォームで同じパス | | Qwen CLI | `~/.qwen/` | `%USERPROFILE%\.qwen\` | すべてのプラットフォームで同じパス | | Roo Code | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\rooveterinaryinc.roo-cline\tasks\` | VS Code globalStorageタスクログ | | Kilo | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\kilocode.kilo-code\tasks\` | VS Code globalStorageタスクログ | +| Cline | Linux: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/`; macOS: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/`; サーバー: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/` | `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\tasks\` | VS Code globalStorageタスクログ | | Mux | `~/.mux/sessions/` | `%USERPROFILE%\.mux\sessions\` | 全プラットフォームで同じパス | | Codebuff | `~/.config/manicode/projects/` (+ `manicode-dev`、`manicode-staging`) | `%USERPROFILE%\.config\manicode\projects\` | `CODEBUFF_DATA_DIR` 環境変数でオーバーライド | | Kilo CLI | `~/.local/share/kilo/` | `%USERPROFILE%\.local\share\kilo\` | OpenCodeと同様に`xdg-basedir`を使用 | | Crush | `$XDG_DATA_HOME/crush/`(フォールバック: `~/.local/share/crush/`) | `%USERPROFILE%\.local\share\crush\`(設定されていれば `%XDG_DATA_HOME%\crush\`) | フォールバック付きでXDGデータディレクトリを使用 | | Goose | `~/.local/share/goose/sessions/` (+ macOS Application Support、レガシー Block パス) | `%USERPROFILE%\.local\share\goose\sessions\` | `GOOSE_PATH_ROOT` 環境変数で設定可能 | | Antigravity | `~/.config/tokscale/antigravity-cache/sessions/` | — | `tokscale antigravity sync` は現在 macOS / Linux でのみサポート | +| Zed Agent | `~/.local/share/zed/threads/threads.db` | `%LOCALAPPDATA%\Zed\threads\threads.db` | ホスティング済み Zed モデルの使用量のみ;外部 ACP エージェントは対象外 | +| Kiro | `~/.kiro/sessions/cli/` および `~/.local/share/kiro-cli/data.sqlite3` | `%USERPROFILE%\.kiro\sessions\cli\` および `%USERPROFILE%\.local\share\kiro-cli\data.sqlite3` | Kiro セッションファイルに加え、存在する場合は Kiro CLI の SQLite データベースを解析 | | Trae | `~/.config/tokscale/trae-cache/sessions/` | `%APPDATA%\tokscale\trae-cache\sessions\` | `tokscale trae sync` で 1 回だけ同期。インストール済みの Trae IDE または Trae Solo デスクトップアプリから資格情報を自動検出 | +| Warp/Oz | `~/.config/tokscale/warp-cache/usage.json` | `%APPDATA%\tokscale\warp-cache\usage.json` | `tokscale warp sync` で同期;集計リクエスト数と使用金額のみ、トークントランスクリプトは含まない | +| Grok Build | `~/.grok/sessions/` | `%USERPROFILE%\.grok\sessions\` | `GROK_HOME` 環境変数で設定可能。`updates.jsonl` セッション更新を解析 | +| Jcode | `~/.jcode/sessions/` | `%USERPROFILE%\.jcode\sessions\` | `JCODE_HOME` 環境変数で設定可能。`session_*.json` スナップショットと `session_*.journal.jsonl` サイドカーを解析 | +| MiMo Code | `~/.local/share/mimocode/` | `%USERPROFILE%\.local\share\mimocode\` | XDG データディレクトリを使用;SQLite データベース `mimocode.db` | | Gajae-Code | `~/.gjc/agent/sessions/` | `%USERPROFILE%\.gjc\agent\sessions\` | `GJC_CODING_AGENT_DIR` で設定可能(`GJC_CONFIG_DIR`/`PI_CONFIG_DIR` も解決;Linux/macOS では `$XDG_DATA_HOME/gjc/sessions/` も対応) | +| Junie | `~/.junie/sessions/` | `%USERPROFILE%\.junie\sessions\` | すべてのプラットフォームで同じホーム相対パス;`events.jsonl` 使用イベントを解析 | +| ZCode | `~/.zcode/cli/db/db.sqlite` および `~/.zcode/projects/` | `%USERPROFILE%\.zcode\cli\db\db.sqlite` および `%USERPROFILE%\.zcode\projects\` | v2 SQLite モデル使用量と従来の `*.jsonl` セッショントランスクリプトを解析;Z.ai の GLM モデル向け ADE | +| OpenCodeReview | `~/.opencodereview/sessions/` | `%USERPROFILE%\.opencodereview\sessions\` | `*.jsonl` セッショントランスクリプトを解析;Alibaba の AI コードレビューツール | +| CodeBuddy | `~/.codebuddy/projects/` + 拡張機能ログ | `%USERPROFILE%\.codebuddy\projects\` + CodeBuddy / VS Code 拡張機能ログ | CodeBuddy CLI・IDE・VS Code プラグインのトークン使用量を解析 | +| WorkBuddy | `~/.workbuddy/projects/` + `~/.workbuddy/workbuddy.db` | `%USERPROFILE%\.workbuddy\projects\` + `%USERPROFILE%\.workbuddy\workbuddy.db` | WorkBuddy のトークン使用量を解析し、集約 SQLite データベースをフォールバックとして使用 | | Synthetic | 他ソースから再帰属 | 他ソースから再帰属 | `hf:`モデル + `synthetic`プロバイダを検出 | > **注**: Windowsでは`~`は`%USERPROFILE%`に展開されます(例:`C:\Users\ユーザー名`)。これらのツールは`%APPDATA%`のようなWindowsネイティブパスではなく、クロスプラットフォームの一貫性のためにUnixスタイルのパス(`.local/share`など)を意図的に使用しています。 @@ -1081,13 +1441,17 @@ OpenCodeはビルド時のリリースチャンネルに応じてDBファイル ### Claude Code -場所: `~/.claude/projects/{projectPath}/*.jsonl` +場所: `~/.claude/projects/{projectPath}/*.jsonl` および `~/.claude/transcripts/*.jsonl` アシスタントメッセージの使用量データを含むJSONL形式: ```json {"type": "assistant", "message": {"model": "claude-sonnet-4-20250514", "usage": {"input_tokens": 1234, "output_tokens": 567, "cache_read_input_tokens": 890}}, "timestamp": "2024-01-01T00:00:00Z"} ``` +`~/.claude/transcripts/` 配下のラッパートランスクリプトファイルは、実際の Claude 使用量メタデータを含む場合のみカウントされます。ユーザー/ツールイベントはあるが `usage` ブロックがないファイルは、推定せずにスキップされます。 + +Tokscale の `claude` クライアントは Claude Code のトークン集計であり、Claude Desktop チャットの集計ではありません。Claude Desktop は `~/Library/Application Support/Claude` などの場所にアプリデータを保存しますが、Anthropic はコンシューマー向けデスクトップチャットやチャット履歴エクスポートについて、安定したローカルのメッセージ単位トークン台帳を文書化していません。Claude Desktop のデータは存在するが Claude Code の JSONL ルートのみがスキャン可能な場合は、`tokscale clients` を実行すると診断が表示されます。`tokscale usage` は Claude Code の認証情報からベストエフォートで Claude サブスクリプションのクォータバーを表示できますが、組織/API 使用量は Anthropic の Admin Usage and Cost API に属し、ローカルのトランスクリプトスキャンとは意図的に分離されています。 + ### Codex CLI 場所: `~/.codex/sessions/*.jsonl` @@ -1162,6 +1526,24 @@ Antigravity データはルートコマンドでは自動取得されません Trae データはルートコマンドでは自動取得されません。最初に `tokscale trae login` を実行し、レポート前に `tokscale trae sync` または `tokscale trae sync --since 30` を実行してください。Tokscale は同期された API dump をセッション単位のレコードとして解析し、Trae が返すコスト合計を保持します。 +### Warp/Oz + +場所: `~/.config/tokscale/warp-cache/usage.json`(認証済み GraphQL API 経由で同期) + +Warp/Oz データはルートコマンドでは自動取得されません。レポートの前に `tokscale warp login` を実行し、続いて `tokscale warp sync` を実行してください。Warp はトークンに紐づくローカルトランスクリプトを公開しないため、Tokscale は集約されたリクエスト数と支出のみを記録します。 + +### Grok Build + +場所: `$GROK_HOME/sessions/*/*/updates.jsonl`(フォールバック: `~/.grok/sessions/*/*/updates.jsonl`) + +Grok Build データはローカルのセッション更新から直接解析されます。現在のログは安定した input/output 分割なしで累積 `totalTokens` カウンターを公開するため、Tokscale はターンごとの正の増分を input トークンとして記録します。`grok-composer-2.5-fast` は専用の公開価格が利用可能になるまで Composer 2.5 Fast 価格 override に一時的にマップされます。 + +### Jcode + +場所: `$JCODE_HOME/sessions/session_*.json`(フォールバック: `~/.jcode/sessions/session_*.json`)と、対応する `session_*.journal.jsonl` サイドカー。 + +Jcode データはローカルのセッションスナップショットから直接解析されます。Tokscale は別のクライアントの識別子を偽装することなく、アシスタントの `messages[].token_usage` フィールド(`input_tokens`、`output_tokens`、`cache_read_input_tokens`、`cache_creation_input_tokens`、`reasoning_output_tokens`)を読み取ります。対応するジャーナルサイドカーは重複排除の前に同じセッションストリームへマージされるため、Jcode がスナップショットにチェックポイントするまでの間も、最近追記されたメッセージが含まれます。リプレイの重複排除には安定したメッセージ ID を使用し、ID を持たない不正/カスタムなレコードにはスコープ付きのフォールバックキーを使用します。 + ### OpenClaw 場所: `~/.openclaw/agents/*/sessions/sessions.json`(レガシーパスもスキャン: `~/.clawdbot/`, `~/.moltbot/`, `~/.moldbot/`) @@ -1184,7 +1566,7 @@ model_changeイベントとアシスタントメッセージを含むセッシ ### Hermes Agent -場所: `$HERMES_HOME/state.db`(フォールバック: `~/.hermes/state.db`) +場所: `$HERMES_HOME/state.db`(フォールバック: `~/.hermes/state.db`)および標準プロファイルデータベース `$HERMES_HOME/profiles/*/state.db`(`HERMES_HOME` がアクティブなプロファイルを指す場合は、同階層の `~/.hermes/profiles/*/state.db`) HermesはSQLiteの`sessions`テーブルにセッションレベルの使用量を保存します。Tokscaleは`model`が存在しトークンまたはコスト合計が0でない行をインポートし、`started_at`をタイムスタンプとして使用し、`message_count`を保持し、`actual_cost_usd`を`estimated_cost_usd`より優先します。 @@ -1208,6 +1590,13 @@ StatusUpdate メッセージを含む wire.jsonl 形式: {"timestamp": 1770983426.420942, "message": {"type": "StatusUpdate", "payload": {"token_usage": {"input_other": 1562, "output": 2463, "input_cache_read": 0, "input_cache_creation": 0}, "message_id": "chatcmpl-xxx"}}} ``` +### Kimi Code + +場所: `~/.kimi-code/sessions/{WORKDIR}/{SESSION_UUID}/agents/{AGENT}/wire.jsonl` +```json +{"type":"usage.record","model":"kimi-code/kimi-for-coding","usage":{"inputOther":1163,"output":352,"inputCacheRead":22272,"inputCacheCreation":0},"usageScope":"turn","time":1780410897480} +``` + ### Qwen CLI 場所: `~/.qwen/projects/{PROJECT_PATH}/chats/{CHAT_ID}.jsonl` @@ -1253,6 +1642,19 @@ KiloはRoo Codeと同じタスクログ形式を使用します。Tokscaleは同 - `text` JSONから`tokensIn`、`tokensOut`、`cacheReads`、`cacheWrites`、`cost`、`apiProtocol`を解析 - 利用可能な場合、隣接する`api_conversation_history.json`からモデル/エージェントメタデータを補完 +### Cline + +場所: +- Linux デスクトップ VS Code: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` +- macOS デスクトップ VS Code: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` +- Windows デスクトップ VS Code: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\tasks\{TASK_ID}\ui_messages.json` +- サーバー(ベストエフォート): `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` + +Cline は Roo Code と Kilo がフォークした元となるアップストリームプロジェクトであり、同じ VS Code globalStorage のタスクログ形式を使用します。Tokscale は同じルールを適用します: +- `ui_messages.json`から`say/api_req_started`イベントのみをカウント +- `text` JSONから`tokensIn`、`tokensOut`、`cacheReads`、`cacheWrites`、`cost`、`apiProtocol`を解析 +- 利用可能な場合、隣接する`api_conversation_history.json`からモデル/エージェントメタデータを補完 + ### Mux 場所: @@ -1304,6 +1706,41 @@ Synthetic は他ソースのメッセージを後処理で再帰属します。` また `~/.local/share/octofriend/sqlite.db` を検出し、トークン情報を持つレコードを取り込みます。 +### MiMo Code + +場所: `~/.local/share/mimocode/mimocode.db`(XDG データディレクトリ) + +MiMo Code は SQLite データベースにセッションデータを保存します。Tokscale はワークスペースコンテキストのために `session` テーブルと結合した `message` テーブルをクエリします: + +```sql +SELECT m.id, m.session_id, m.data, NULLIF(s.directory, '') AS workspace_root +FROM message m +LEFT JOIN session s ON s.id = m.session_id +WHERE json_extract(m.data, '$.role') = 'assistant' + AND json_extract(m.data, '$.tokens') IS NOT NULL +``` + +`data` カラムは JSON 形式で、以下のトークン関連フィールドを含みます: +```json +{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "providerID": "anthropic", + "cost": 0.0032, + "tokens": { + "input": 1200, + "output": 450, + "reasoning": 0, + "cache": { "read": 800, "write": 0 } + }, + "time": { "created": 1780410897000, "completed": 1780410912000 }, + "agent": "micode", + "path": { "root": "/Users/me/project" } +} +``` + +Tokscale はタイムスタンプ、モデル、プロバイダ、トークン数、コスト、エージェント名のフィンガープリントを使用して、フォークされたセッション間のメッセージを重複排除します。 + ## 価格 Tokscaleは[LiteLLMの価格データベース](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)からリアルタイム価格を取得します。 @@ -1312,6 +1749,8 @@ Tokscaleは[LiteLLMの価格データベース](https://github.com/BerriAI/litel **Cursorモデル価格**: LiteLLMとOpenRouterの両方にまだ存在しない最新モデル(例:`gpt-5.3-codex`)は、[Cursorモデルドキュメント](https://cursor.com/en-US/docs/models)から取得したハードコード価格を使用します。これらのオーバーライドはすべてのアップストリームソースの後、ファジーマッチングの前にチェックされるため、実際のアップストリーム価格が利用可能になると自動的に優先されます。 +**Sakana Fugu価格**: Fugu UltraのコストはSakanaが公開している従量課金(pay-as-you-go)レートから推定します。`fugu`ルーターモデルは、実際にオーケストレーションした基盤モデルの変動レートがそのままコストになるため、意図的に価格を設定していません。 + **キャッシュ**: 価格データは1時間TTLでディスクにキャッシュされ、高速な起動を確保します: - LiteLLMキャッシュ: `~/.config/tokscale/cache/pricing-litellm.json` - OpenRouterキャッシュ: `~/.config/tokscale/cache/pricing-openrouter.json`(サポート対象プロバイダーのモデル作成者価格をキャッシュ) @@ -1322,7 +1761,7 @@ Tokscaleは[LiteLLMの価格データベース](https://github.com/BerriAI/litel - キャッシュ読み取りトークン(割引) - キャッシュ書き込みトークン - 推論トークン(o1などのモデル用) -- 階層型価格(200kトークン以上) +- モデル固有の階層型価格(例: 200k または 272k トークン以上) ## コントリビューション diff --git a/README.ko.md b/README.ko.md index 0b330b699..1e308fc77 100644 --- a/README.ko.md +++ b/README.ko.md @@ -52,34 +52,49 @@ **Tokscale**은 아래 플랫폼들의 **토큰 소비량을 수집하고 분석**해 한 눈에 볼 수 있도록 해 줍니다. -| 로고 | 클라이언트 | 데이터 위치 | 지원 여부 | -|------|----------|---------------|-----------| -| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+, `opencode-stable.db` 등 모든 채널 포함) 또는 `~/.local/share/opencode/storage/message/` | ✅ 지원 | -| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` | ✅ 지원 | -| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ 레거시: `.clawdbot`, `.moltbot`, `.moldbot`) | ✅ 지원 | -| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | ✅ 지원 | -| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | ✅ 지원 | -| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db` (폴백: `~/.hermes/state.db`) | ✅ 지원 | -| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json` (폴백: `~/.gemini/tmp/*/chats/*.json`) | ✅ 지원 | -| Cursor | [Cursor IDE](https://cursor.com/) | `~/.config/tokscale/cursor-cache/`를 통한 API 동기화 | ✅ 지원 | -| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | ✅ 지원 | -| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/` (+ `manicode-dev`, `manicode-staging`; `CODEBUFF_DATA_DIR`로 오버라이드 가능) | ✅ 지원 | -| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | ✅ 지원 | -| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | ✅ 지원 | -| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | `~/.kimi/sessions/` | ✅ 지원 | -| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | ✅ 지원 | -| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | ✅ 지원 | -| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | ✅ 지원 | -| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | ✅ 지원 | -| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | ✅ 지원 | -| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json` (프로젝트 레지스트리, 기본값: `~/.local/share/crush/projects.json`) | ✅ 지원 | -| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db` (+ macOS Application Support, 레거시 Block/goose 경로; `GOOSE_PATH_ROOT`로 오버라이드 가능) | ✅ 지원 | -| Antigravity | [Google Antigravity](https://antigravity.google/) | `tokscale antigravity sync`로 `~/.config/tokscale/antigravity-cache/sessions/*.jsonl`에 캐싱 (로컬 언어 서버 RPC 사용) | ✅ 지원 | -| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo) (국제판) | `tokscale trae sync`로 `~/.config/tokscale/trae-cache/sessions/*.json`에 캐싱 (공식 API의 계정 단위 사용량) | ✅ 지원 | -| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db` (macOS: `~/Library/Application Support/Zed/threads/threads.db`; Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`; 호스팅된 Zed 모델 전용, 외부 ACP 에이전트 제외) | ✅ 지원 | -| Kiro | Kiro | `~/.kiro/sessions/cli/*.json` (+ `*.jsonl`) 및 `~/.local/share/kiro-cli/data.sqlite3` (macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`) | ✅ 지원 | -| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/` (`GJC_CODING_AGENT_DIR`, `GJC_CONFIG_DIR`, `PI_CONFIG_DIR`로 오버라이드 가능; Linux/macOS에서는 `$XDG_DATA_HOME/gjc/sessions/`도 확인) | ✅ 지원 | -| Synthetic | [Synthetic](https://synthetic.new/) | `hf:` 모델/`synthetic` provider 감지로 다른 소스에서 재귀속 (+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | ✅ 지원 | +| 로고 | 클라이언트 | 데이터 위치 | +|------|----------|---------------| +| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+, `opencode-stable.db` 등 모든 채널 포함) 또는 `~/.local/share/opencode/storage/message/` | +| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` 및 `~/.claude/transcripts/` | +| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ 레거시: `.clawdbot`, `.moltbot`, `.moldbot`) | +| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | +| Sakana Fugu | [Sakana Fugu](https://sakana.ai/fugu/) | Codex를 통해 추적 — `~/.codex/sessions/*.jsonl` (`model_provider: sakana`) | +| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | +| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db` 및 `$HERMES_HOME/profiles/*/state.db` (폴백: `~/.hermes/...`) | +| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json` (폴백: `~/.gemini/tmp/*/chats/*.json`) | +| Cursor | [Cursor IDE](https://cursor.com/) | Cursor API 내보내기를 `~/.config/tokscale/cursor-cache/usage*.csv`에 캐싱 (`~/.cursor` 아님) | +| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | +| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/` (+ `manicode-dev`, `manicode-staging`; `CODEBUFF_DATA_DIR`로 오버라이드 가능) | +| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | +| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | +| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) / [Kimi Code](https://github.com/MoonshotAI/kimi-code) | kimi-cli: `~/.kimi/sessions/` kimi-code: `~/.kimi-code/sessions/` (override via `KIMI_CODE_HOME`) | +| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | +| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | +| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | +| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | +| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | +| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json` (프로젝트 레지스트리, 기본값: `~/.local/share/crush/projects.json`) | +| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db` (+ macOS Application Support, 레거시 Block/goose 경로; `GOOSE_PATH_ROOT`로 오버라이드 가능) | +| Antigravity | [Google Antigravity](https://antigravity.google/) | `tokscale antigravity sync`로 `~/.config/tokscale/antigravity-cache/sessions/*.jsonl`에 캐싱 (로컬 언어 서버 RPC 사용) | +| Antigravity CLI | [Antigravity CLI](https://antigravity.google/) | `~/.gemini/antigravity-cli/conversations/*.db` (`GEMINI_CLI_HOME`로 Gemini 홈 경로 오버라이드 가능; 로컬 SQLite를 직접 읽으므로 `antigravity sync`가 필요 없음) | +| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo) (국제판) | `tokscale trae sync`로 `~/.config/tokscale/trae-cache/sessions/*.json`에 캐싱 (공식 API의 계정 단위 사용량) | +| Warp | [Warp](https://www.warp.dev/) / Oz | `tokscale warp sync`로 `~/.config/tokscale/warp-cache/usage.json`에 캐싱 (집계된 요청 수 및 비용만; 토큰 트랜스크립트 없음) | +| Grok Build | Grok Build | `$GROK_HOME/sessions/*/*/updates.jsonl` (폴백: `~/.grok/sessions/*/*/updates.jsonl`) | +| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db` (macOS: `~/Library/Application Support/Zed/threads/threads.db`; Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`; 호스팅된 Zed 모델 전용, 외부 ACP 에이전트 제외) | +| Kiro | Kiro | `~/.kiro/sessions/cli/*.json` (+ `*.jsonl`), `~/.local/share/kiro-cli/data.sqlite3` (macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`), 그리고 Kiro IDE globalStorage 스냅샷 (`Kiro/User/globalStorage/kiro.kiroagent`; macOS Application Support, Linux `~/.config/Kiro`, Windows `%APPDATA%\Kiro`) | +| Cline | [Cline](https://github.com/cline/cline) | VS Code globalStorage tasks (Linux: `~/.config/Code/...`; macOS: `~/Library/Application Support/Code/...`; Windows: `%APPDATA%\Code\...`; server: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/`) | +| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/` (`GJC_CODING_AGENT_DIR`, `GJC_CONFIG_DIR`, `PI_CONFIG_DIR`로 오버라이드 가능; Linux/macOS에서는 `$XDG_DATA_HOME/gjc/sessions/`도 확인) | +| Jcode | [Jcode](https://github.com/1jehuang/jcode) | `~/.jcode/sessions/session_*.json` + `session_*.journal.jsonl` 사이드카 (`JCODE_HOME`으로 재정의 가능) | +| MiMo Code | [MiMo Code](https://github.com/XiaomiMiMo/MiMo-Code) | `~/.local/share/mimocode/mimocode.db` (XDG 데이터 디렉토리; SQLite) | +| Junie | [Junie](https://www.jetbrains.com/junie/) | `~/.junie/sessions/*/events.jsonl` | +| Command Code | [Command Code](https://github.com/CommandCodeAI/command-code) | `~/.commandcode/projects/**/*.jsonl` (토큰 사용량은 트랜스크립트에서 토큰당 약 4자 기준으로 추정; 디스크에 저장되지 않음) | +| ZCode | [ZCode](https://zcode.z.ai/) | `~/.zcode/cli/db/db.sqlite`(v2 사용량 데이터베이스) 및 `~/.zcode/projects/**/*.jsonl`(레거시 기록) | +| OpenCodeReview | [OpenCodeReview](https://github.com/alibaba/open-code-review) | `~/.opencodereview/sessions/**/*.jsonl` | +| CodeBuddy | [CodeBuddy](https://www.codebuddy.cn/docs/cli/overview) (CLI, IDE, VS Code 플러그인) | `~/.codebuddy/projects/**/*.jsonl` + 확장 프로그램 로그 | +| WorkBuddy | WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` + SQLite 폴백 | +| Devin CLI | [Devin CLI](https://devin.ai/) | `~/.local/share/devin/cli/sessions.db` (SQLite) | +| Devin Desktop | [Devin Desktop](https://devin.ai/) | ACP 이벤트: macOS `~/Library/Application Support/Devin/User/acp-events/`; Linux `~/.config/Devin/User/acp-events/`; Windows `%APPDATA%\Devin\User\acp-events\` | +| Synthetic | [Synthetic](https://synthetic.new/) | `hf:` 모델/`synthetic` provider 감지로 다른 소스에서 재귀속 (+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | [🚅 LiteLLM의 가격 데이터](https://github.com/BerriAI/litellm)를 사용해 **실시간 비용 계산**을 제공합니다. 구간별 가격 모델(대용량 컨텍스트 등)과 **캐시 토큰 할인**도 지원합니다. @@ -105,10 +120,15 @@ AI 지원 개발 시대에 **토큰은 새로운 에너지**입니다. 토큰은 - [플랫폼별 필터링](#플랫폼별-필터링) - [날짜 필터링](#날짜-필터링) - [가격 조회](#가격-조회) + - [사용자 정의 가격 오버라이드](#사용자-정의-가격-오버라이드) - [소셜 플랫폼 명령어](#소셜-플랫폼-명령어) + - [Autosubmit](#autosubmit) - [Cursor IDE 명령어](#cursor-ide-명령어) - [Antigravity 명령어](#antigravity-명령어) - [Trae 명령어](#trae-명령어) + - [Warp/Oz 명령어](#warpoz-명령어) + - [작업 기반 리포트](#작업-기반-리포트) + - [구독 사용량](#구독-사용량) - [예시 출력](#예시-출력---light-버전) - [설정](#설정) - [환경 변수](#환경-변수) @@ -143,15 +163,16 @@ AI 지원 개발 시대에 **토큰은 새로운 에너지**입니다. 토큰은 - **인터랙티브 TUI 모드** - Ratatui 기반의 터미널 UI (기본 모드) - 6개 인터랙티브 뷰: 개요, 모델, 일별, 시간별, 통계, 에이전트 (선택적 Minutely 뷰는 `minutelyTabEnabled`로 활성화) - 키보드 및 마우스 지원 - - 9가지 테마의 GitHub 스타일 기여 그래프 + - 설정 가능한 색상 테마의 GitHub 스타일 기여 그래프 - 실시간 필터링 및 정렬 - 깜빡임 없는 렌더링 -- **멀티 플랫폼 지원** - OpenCode, Claude Code, Codex CLI, Copilot CLI, Cursor IDE, Gemini CLI, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi CLI, Qwen CLI, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Zed, Kiro, Trae, Gajae-Code, Synthetic 사용량 통합 추적 +- **멀티 플랫폼 지원** - OpenCode, Claude Code, Codex CLI, Copilot CLI, Cursor IDE, Gemini CLI, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi CLI, Qwen CLI, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Antigravity CLI, Zed, Kiro, Trae, Warp/Oz, Cline, Gajae-Code, Grok Build, Jcode, MiMo Code, Command Code, Junie, ZCode, OpenCodeReview, CodeBuddy, WorkBuddy, Devin CLI, Devin Desktop, Synthetic 사용량 통합 추적 - **실시간 가격 반영** - LiteLLM에서 최신 가격을 가져와(디스크 캐시 1시간) 비용 계산; OpenRouter 자동 폴백 및 신규 모델용 Cursor 가격 지원 - **상세 분석** - 입력, 출력, 캐시 읽기/쓰기, 추론 토큰까지 추적 - **네이티브 Rust 코어** - 모든 파싱과 집계를 Rust로 처리해 최대 10배 빠른 성능 - **웹 시각화** - 2D 및 3D 뷰의 인터랙티브 기여 그래프 - **유연한 필터링** - 플랫폼, 날짜 범위 또는 연도별 필터링 +- **작업 기반 리포트** - LLM 기반 세션 요약 및 작업 그룹화, 여러 백엔드 지원 (Apple FM, Claude, Codex, Gemini, Kiro) - **JSON 내보내기** - 외부 시각화 도구/자동화용 데이터 생성 - **소셜 플랫폼** - 사용량 공유, 리더보드 경쟁, 공개 프로필 조회 @@ -247,7 +268,7 @@ tokscale models --json > report.json # 파일로 저장 인터랙티브 TUI 모드는 다음을 제공합니다: -- **6개 뷰**: 개요 (차트 + 상위 모델), 모델, 일별, 시간별, 통계 (기여 그래프), 에이전트 +- **8개 뷰**: 개요 (차트 + 상위 모델), Usage (구독 할당량), 모델, 일별, 시간별, 통계 (기여 그래프), 에이전트. 분 단위 뷰(Minutely)는 기본적으로 숨겨져 있으며 `settings.json`의 `minutelyTabEnabled`로 활성화할 수 있습니다 — [설정](#설정) 참조 - **키보드 내비게이션**: - `←/→/Tab/BackTab`: 뷰 전환 - `↑/↓` 또는 `Home/End`: 목록 탐색 @@ -256,16 +277,16 @@ tokscale models --json > report.json # 파일로 저장 - `c/d/t`: 비용/날짜/토큰별 정렬 - `j`: 오늘로 이동 - `s`: 소스 선택 다이얼로그 열기 - - `g`: 그룹 기준 선택 다이얼로그 열기 (모델, 클라이언트+모델, 클라이언트+프로바이더+모델) + - `g`: 그룹 기준 선택 다이얼로그 열기 (모델, 클라이언트+모델, 클라이언트+프로바이더+모델, 워크스페이스+모델, 세션+모델, 클라이언트+세션+모델) - `h`: Daily/Hourly 차트 단위 전환 (Overview 탭) - `v`: Table/Profile 뷰 전환 (Hourly 탭) - `y`: 선택된 행을 클립보드에 복사 - - `p`: 9가지 색상 테마 순환 + - `p`: 색상 테마 순환 - `r`: 데이터 새로고침; `Shift+R`로 자동 새로고침 토글; `+`/`-`로 간격 조정 - `e`: JSON으로 내보내기 - `q` 또는 `Ctrl+C`: 종료 - **마우스 지원**: 탭, 버튼, 필터 클릭 -- **테마**: Green, Halloween, Teal, Blue, Pink, Purple, Orange, Monochrome, YlGnBu +- **테마**: Green, Halloween, Teal, Blue, Pink, Purple, Orange, Monochrome, YlGnBu, Graphite, Lagoon, Dusk - **설정 저장**: 설정이 `~/.config/tokscale/settings.json`에 저장됨 ([설정](#설정) 참조) ### 그룹 기준 전략 @@ -277,6 +298,9 @@ TUI에서 `g`를 누르거나 `--light`/`--json` 모드에서 `--group-by`를 | **모델** | `--group-by model` | ✅ | 모델당 한 행 — 모든 클라이언트와 프로바이더 병합 | | **클라이언트 + 모델** | `--group-by client,model` | | 클라이언트-모델 쌍당 한 행 | | **클라이언트 + 프로바이더 + 모델** | `--group-by client,provider,model` | | 가장 세분화 — 병합 없음 | +| **워크스페이스 + 모델** | `--group-by workspace,model` | | 로컬 사용량을 워크스페이스 키별로, 그 다음 모델별로 그룹화 | +| **세션 + 모델** | `--group-by session,model` | | `session_id`와 모델당 한 행 — 특정 에이전트-CLI 세션에 비용 귀속 | +| **클라이언트 + 세션 + 모델** | `--group-by client,session,model` | | 클라이언트, 세션, 모델당 한 행 — `session_id`로 조인하는 멀티 에이전트 러너에 유용 | **`--group-by model`** (가장 통합) @@ -300,6 +324,33 @@ TUI에서 `g`를 누르거나 `--light`/`--json` 모드에서 `--group-by`를 | OpenCode | anthropic | claude-opus-4-5 | $168 | | Claude | anthropic | claude-opus-4-5 | $970 | +**`--group-by session,model`** (세션별 비용 귀속) + +`tokscale models --json --group-by session,model`은 `(session_id, model)`당 하나의 항목을 출력합니다. 각 항목은 최상위 `sessionId` 필드를 포함하므로, 다운스트림 도구(예: 멀티 에이전트 IDE)가 비용 데이터를 특정 에이전트-CLI 세션에 다시 조인할 수 있습니다: + +```json +{ + "groupBy": "session,model", + "entries": [ + { + "sessionId": "019e1e27-af49-7cd1-89b7-7bad1c3f3be2", + "client": "codex", + "provider": "openai", + "model": "gpt-5", + "input": 25251, + "output": 47, + "cacheRead": 1920, + "cacheWrite": 0, + "reasoning": 40, + "messageCount": 12, + "cost": 0.0123 + } + ] +} +``` + +모든 행에 클라이언트 이름도 필요하다면 `--group-by client,session,model`을 사용하세요 (20개 이상 지원되는 모든 CLI에 걸친 단일 스폰). + ### 플랫폼별 필터링 `--client` (단축형 `-c`) 플래그로 하나 이상의 클라이언트로 리포트 범위를 좁힐 수 있습니다. 반복 사용 가능하며 콤마로 구분된 값도 지원하고, 모든 리포트 명령에서 동작합니다: @@ -314,7 +365,7 @@ tokscale --client opencode,claude # 반복: 같은 효과 (쉘 alias와 함께 쓰기 좋음) tokscale -c opencode -c claude -# Cursor IDE는 사전에 `tokscale cursor login` 필요 +# Cursor IDE는 Tokscale의 API 캐시를 사용; 먼저 login + sync --json 실행 tokscale --client cursor # Synthetic (synthetic.new) 은 다른 에이전트 세션에서 검출됨 @@ -324,9 +375,9 @@ tokscale --client synthetic tokscale --client opencode,claude --week --json ``` -가능한 값: `opencode`, `claude`, `codex`, `copilot`, `gemini`, `cursor`, `amp`, `codebuff`, `droid`, `openclaw`, `hermes`, `pi`, `kimi`, `qwen`, `roocode`, `kilocode`, `kilo`, `mux`, `crush`, `goose`, `antigravity`, `zed`, `kiro`, `trae`, `gjc`, `synthetic`. +가능한 값: `opencode`, `claude`, `codex`, `copilot`, `gemini`, `cursor`, `amp`, `codebuff`, `droid`, `openclaw`, `hermes`, `pi`, `kimi`, `qwen`, `roocode`, `kilocode`, `kilo`, `mux`, `crush`, `goose`, `antigravity`, `antigravity-cli`, `zed`, `kiro`, `trae`, `warp`, `cline`, `gjc`, `grok`, `jcode`, `micode`, `commandcode`, `junie`, `zcode`, `synthetic`. -> **Deprecation 안내**: 기존 단일 클라이언트 플래그 (`--opencode`, `--claude`, `--codex` 등)는 하위 호환성을 위해 여전히 동작하지만 `--help`에서 숨겨졌으며 다음 메이저 릴리스에서 제거됩니다. 가능한 한 `--client`로 마이그레이션하세요. 인터랙티브 터미널에서 레거시 플래그를 사용하면 한 줄 경고가 출력됩니다. +> **Breaking change (v4.0.0):** 클라이언트별 boolean 플래그(`--opencode`, `--claude`, `--codex` 등)는 제거되었으며 이제 오류를 발생시킵니다. 대신 정식 `--client`/`-c` 플래그를 사용하세요 — 예: `tokscale --client opencode,claude`. ### 날짜 필터링 @@ -335,6 +386,7 @@ tokscale --client opencode,claude --week --json ```bash # 빠른 날짜 단축키 tokscale --today # 오늘만 +tokscale --yesterday # 어제만 tokscale --week # 최근 7일 tokscale --month # 이번 달 @@ -365,19 +417,55 @@ tokscale pricing "grok-code" # 특정 프로바이더 소스 강제 지정 tokscale pricing "grok-code" --provider openrouter tokscale pricing "claude-3-5-sonnet" --provider litellm + +# 사용자 정의 가격 오버라이드 확인 +tokscale pricing list-overrides ``` **조회 전략:** 가격 조회는 다단계 해석 전략을 사용합니다: -1. **정확한 일치** - LiteLLM/OpenRouter 데이터베이스에서 직접 조회 -2. **별칭 해석** - 친숙한 이름 해석 (예: `big-pickle` → `glm-4.7`) -3. **티어 접미사 제거** - 품질 티어 제거 (`gpt-5.2-xhigh` → `gpt-5.2`) -4. **버전 정규화** - 버전 형식 처리 (`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) -5. **프로바이더 접두사 매칭** - 일반 접두사 시도 (`anthropic/`, `openai/` 등) -6. **Cursor 모델 가격** - LiteLLM/OpenRouter에 아직 없는 모델의 하드코딩 가격 (예: `gpt-5.3-codex`) -7. **퍼지 매칭** - 부분 모델 이름에 대한 단어 경계 매칭 +1. **사용자 정의 가격 오버라이드** - `~/.config/tokscale/custom-pricing.json`의 정확한 사용자 정의 항목 +2. **정확한 일치** - LiteLLM/OpenRouter 데이터베이스에서 직접 조회 +3. **별칭 해석** - 친숙한 이름 해석 (예: `big-pickle` → `glm-4.7`) +4. **티어 접미사 제거** - 품질 티어 제거 (`gpt-5.2-xhigh` → `gpt-5.2`) +5. **버전 정규화** - 버전 형식 처리 (`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) +6. **프로바이더 접두사 매칭** - 일반 접두사 시도 (`anthropic/`, `openai/` 등) +7. **Cursor 모델 가격** - LiteLLM/OpenRouter에 아직 없는 모델의 하드코딩 가격 (예: `gpt-5.3-codex`) +8. **퍼지 매칭** - 부분 모델 이름에 대한 단어 경계 매칭 + +### 사용자 정의 가격 오버라이드 + +업스트림 가격 데이터베이스가 아직 정확히 다루지 못하는 모델 ID의 가격을 오버라이드하려면 Tokscale의 설정 디렉터리(기본값은 macOS/Linux의 `~/.config/tokscale/custom-pricing.json`; `TOKSCALE_CONFIG_DIR`가 설정된 경우 동일한 디렉터리로 해석됨)에 `custom-pricing.json`을 생성하세요. + +```json +{ + "$schema": "https://tokscale.ai/custom-pricing.schema.json", + "models": { + "accounts/fireworks/routers/kimi-k2p6-turbo": { + "input_cost_per_million_tokens": 2.00, + "output_cost_per_million_tokens": 8.00, + "cache_read_input_token_cost_per_million_tokens": 0.30, + "source": "https://docs.fireworks.ai/serverless/pricing", + "notes": "Fireworks Kimi K2.6 Turbo (preview)" + }, + "accounts/fireworks/models/kimi-k2p6": { + "input_cost_per_million_tokens": 0.95, + "output_cost_per_million_tokens": 4.00, + "cache_read_input_token_cost_per_million_tokens": 0.16 + }, + "kimi-k2p6-turbo": { + "input_cost_per_million_tokens": 2.00, + "output_cost_per_million_tokens": 8.00 + } + } +} +``` + +오버라이드 가격은 대부분의 API 프로바이더가 가격을 공개하는 방식과 같이 백만 토큰당 달러 단위로 입력하며, Tokscale은 내부적으로 토큰당 요율로 변환합니다. `input_cost_per_million_tokens` 또는 `output_cost_per_million_tokens` 중 적어도 하나는 존재하고 양수여야 하며, 캐시 읽기/캐시 생성 필드는 선택 사항입니다. 복사/붙여넣기 호환성을 위해 `input_cost_per_token`, `output_cost_per_token`, `cache_read_input_token_cost` 같은 LiteLLM 스타일의 토큰당 필드명도 허용되지만, 백만 토큰당 이름이 권장되는 사용자용 형식입니다. 티어나 캐시 가격을 생략하려면 해당 필드를 비워 두세요. 음수이거나 유한하지 않은 값은 잘못된 것으로 처리되어 오타가 회계를 조용히 바꾸지 않도록 해당 모델 항목 전체를 건너뜁니다. 선택적 `source` 및 `notes` 필드는 Tokscale이 무시하므로 사용자 자신의 기록용으로 사용할 수 있습니다. + +오버라이드는 정확 일치 전용이며 대소문자를 구분하지 않습니다. Tokscale은 원본 모델 ID를 먼저 확인하고, 그다음 기존 합성 `/models/` 정규화를 확인한 뒤, 일치하는 오버라이드가 없으면 LiteLLM, OpenRouter, Cursor 가격, 퍼지 매칭으로 넘어갑니다. 원본 정확 일치가 정규화된 정확 일치보다 우선하므로, `accounts/fireworks/routers/kimi-k2p6-turbo`는 특정 게이트웨이 모델을 오버라이드할 수 있고 `kimi-k2p6-turbo`는 정규화된 `/models/` 경로를 커버할 수 있습니다. 오버라이드는 시작 시 한 번 로드되므로 파일을 편집한 후에는 명령을 다시 실행하세요. 업스트림 LiteLLM 가격 업데이트를 기다리는 동안 잘못된 모델 가격 버그를 로컬에서 수정하는 권장 방법입니다. **프로바이더 우선순위:** @@ -399,12 +487,29 @@ tokscale pricing "claude-3-5-sonnet" --provider litellm # Tokscale 로그인 (GitHub 인증을 위해 브라우저 열기) tokscale login +# 브라우저 인증 없이 기존 Tokscale API 토큰 저장 +tokscale login --token tt_xxx + # 로그인한 사용자 확인 tokscale whoami +# 저장된 API 토큰을 QR 코드로 표시 (다른 기기로 공유할 때 유용) +# {"token":"tt_xxx","username":"..."}를 인코딩 — 아무 QR 리더로 스캔 +tokscale qr + # 사용량 데이터를 리더보드에 제출 tokscale submit +# 자격 증명을 기록하지 않고 CI/헤드리스 환경에서 제출 +# 우선순위: TOKSCALE_API_TOKEN 환경 변수 > 저장된 자격 증명 파일 (~/.config/tokscale/credentials.json). +# 환경 변수가 설정되면 해당 실행에서는 저장된 파일이 무시됩니다. +TOKSCALE_API_TOKEN=tt_xxx tokscale submit + +# 토큰 폐기: 리더보드 사이트의 Settings > API Tokens +# (https://tokscale.ai/settings)를 방문해 해당 토큰 행의 "Revoke"를 클릭. +# 폐기는 즉시 적용됩니다 — 이후 해당 토큰을 사용한 요청은 +# HTTP 401 "Invalid API token"을 받습니다. + # 필터와 함께 제출 tokscale submit --client opencode,claude --since 2024-01-01 @@ -417,13 +522,50 @@ tokscale logout CLI Submit +### Autosubmit + +Autosubmit은 일반적인 `tokscale submit` 흐름을 운영체제 스케줄러에 등록합니다. 터미널에서 수동으로 실행하지 않아도 공개 프로필을 최신 상태로 유지할 수 있어 유용합니다. + +```bash +# 주기적 제출을 활성화합니다. macOS에서는 launchd, Linux에서는 가능한 경우 systemd 사용자 타이머 +# (폴백으로 cron), Windows에서는 Windows 작업 스케줄러를 사용합니다. +tokscale autosubmit enable --interval 24h + +# submit에 전달하는 것과 동일한 클라이언트/날짜 필터를 그대로 지정할 수 있습니다. +tokscale autosubmit enable --interval 2h --client opencode,claude --week + +# 저장된 설정과 마지막 실행/오류를 표시합니다. +tokscale autosubmit status +tokscale autosubmit status --json + +# 저장된 간격이 지나지 않았더라도 지금 한 번 실행합니다. +tokscale autosubmit run --force + +# Autosubmit을 비활성화하고 스케줄러 항목을 제거합니다. +tokscale autosubmit disable +``` + +예약된 실행은 비대화형입니다. GitHub 인증이나 스타 확인을 묻지 않습니다. `tokscale login --token tt_xxx`를 한 번 실행하거나 스케줄러 환경에서 `TOKSCALE_API_TOKEN`을 설정하세요. Tokscale은 스케줄러 상태를 `settings.json`에 기록하고, 로그를 `~/.config/tokscale/autosubmit/` 아래에 기록하며, 잠금 파일을 사용해 스케줄러 틱이 겹쳐도 두 번 제출하지 않도록 합니다. + ### Cursor IDE 명령어 -Cursor IDE는 세션 토큰을 통한 별도의 인증이 필요합니다 (소셜 플랫폼 로그인과 다름): +Cursor IDE 지원은 Cursor의 웹 API 내보내기를 사용하며, Tokscale이 `~/.config/tokscale/cursor-cache/usage*.csv`에 캐싱합니다. Tokscale은 `~/.cursor` 아래의 로컬 Cursor Agent CLI 상태를 파싱하지 않습니다. + +설정: + +1. 브라우저에서 https://www.cursor.com/settings 를 열고 로그인하세요. +2. `WorkosCursorSessionToken` 쿠키 값을 복사하세요: + - Network 탭: `cursor.com/api/*`로 아무 요청이나 보낸 뒤, `Cookie` 요청 헤더에서 `WorkosCursorSessionToken=` 뒤의 값을 복사합니다. + - Application 탭: Cookies → `https://www.cursor.com`을 열고 `WorkosCursorSessionToken` 값을 복사합니다. +3. `tokscale cursor login --name work`를 실행하고 토큰을 붙여 넣으세요. +4. `tokscale cursor sync --json`을 실행해 `~/.config/tokscale/cursor-cache/usage.csv`를 채우세요. +5. `tokscale --client cursor` 또는 아무 리포트 명령을 실행하세요. + +세션 토큰은 비밀번호처럼 취급하세요. 토큰은 `~/.config/tokscale/cursor-credentials.json`에 로컬로 저장됩니다. ```bash # Cursor 로그인 (브라우저에서 세션 토큰 필요) -# --name은 선택이며, 나중에 계정을 구분하기 위한 라벨입니다 +# --name은 선택이며, 나중에 계정을 구분하는 데만 도움이 됩니다 tokscale cursor login --name work # Cursor 인증 상태 및 세션 유효성 확인 @@ -432,7 +574,10 @@ tokscale cursor status # 저장된 Cursor 계정 목록 tokscale cursor accounts -# 활성 계정 전환 (cursor-cache/usage.csv에 동기화되는 계정) +# 캐시된 Cursor 사용량 수동 새로고침 +tokscale cursor sync --json + +# 활성 계정 전환 (cursor-cache/usage.csv에 동기화되는 계정 제어) tokscale cursor switch work # 특정 계정 로그아웃 (기록은 보관, 합산에서는 제외) @@ -448,19 +593,9 @@ tokscale cursor logout --all tokscale cursor logout --all --purge-cache ``` -**자격 증명 저장**: Cursor 계정들은 `~/.config/tokscale/cursor-credentials.json`에 저장됩니다. 사용량 데이터는 `~/.config/tokscale/cursor-cache/`에 캐시됩니다 (활성 계정은 `usage.csv`, 추가 계정은 `usage..csv`). - -기본적으로 tokscale은 **저장된 모든 Cursor 계정의 사용량을 합산**합니다 (`cursor-cache/usage*.csv` 전체). 호환성을 위해 활성 계정은 `cursor-cache/usage.csv`에 동기화됩니다. - -로그아웃 시에는 캐시된 사용량 기록을 `cursor-cache/archive/`로 옮겨 보관하며(그래서 합산에서는 제외됨), 완전 삭제를 원하면 `--purge-cache`를 사용하세요. +기본적으로 Tokscale은 `cursor-cache/usage*.csv`를 읽어 저장된 모든 Cursor 계정의 사용량을 합산합니다. 활성 계정은 `usage.csv`에 동기화되고, 추가 계정은 `usage..csv`에 동기화됩니다. -**Cursor 세션 토큰 얻는 방법:** -1. 브라우저에서 https://www.cursor.com/settings 열기 -2. 개발자 도구 열기 (F12) -3. **옵션 A - Network 탭**: 페이지에서 아무 동작을 하고, `cursor.com/api/*`에 대한 요청을 찾아, Request Headers에서 `Cookie` 헤더를 확인하고, `WorkosCursorSessionToken=` 뒤의 값만 복사 -4. **옵션 B - Application 탭**: Application → Cookies → `https://www.cursor.com`으로 이동, `WorkosCursorSessionToken` 쿠키를 찾아 값 복사 (쿠키 이름이 아닌 값) - -> ⚠️ **보안 경고**: 세션 토큰을 비밀번호처럼 취급하세요. 절대 공개적으로 공유하거나 버전 관리에 커밋하지 마세요. 토큰은 Cursor 계정에 대한 전체 액세스 권한을 부여합니다. +로그아웃 시 Tokscale은 캐시된 사용량을 `cursor-cache/archive/`로 옮겨 더 이상 합산되지 않도록 합니다. 캐시된 사용량을 대신 삭제하려면 `--purge-cache`를 사용하세요. ### Antigravity 명령어 @@ -511,8 +646,181 @@ tokscale trae logout --variant solo **동작 방식**: tokscale은 데스크톱 클라이언트의 `iCubeAuthInfo://*` blob(`globalStorage/storage.json`)을 복호화해 JWT를 얻거나, `--manual`로 붙여 넣은 JWT를 사용합니다. 이후 `POST /trae/api/v1/pay/query_user_usage_group_by_session`을 페이지 단위로 호출하고 원본 JSON을 저장합니다. 최신 Trae 데이터를 반영하려면 리포트 실행 전에 sync를 먼저 실행하세요. +> **가격에 대한 참고**: Trae 비용 수치는 **벤더가 보고한 값**입니다 — tokscale은 토큰 수로부터 tokscale의 가격 엔진을 통해 비용을 재계산하는 대신 Trae 자체 API가 반환한 `dollar_float` 값을 그대로 표시합니다. 따라서 수치는 동일한 사용량에 대해 tokscale이 계산했을 값이 아니라 `trae.ai/account-setting#usage`에서 보이는 값과 일치합니다. + > **중국판**: 중국판(`trae.com.cn`)은 의도적으로 지원하지 않습니다. CN 백엔드는 세션 단위 사용량 조회 API를 공개하지 않습니다. 공식 엔드포인트가 제공되면 지원을 추가할 예정입니다. +### Warp/Oz 명령어 + +Warp/Oz는 로컬 토큰 트랜스크립트를 제공하지 않습니다. Tokscale은 Warp의 GraphQL API가 반환하는 집계 요청 수와 비용 카운터만 동기화하며, 이를 토큰 버킷이 0인 `warp` / `aggregate-requests` 행으로 표시합니다. + +```bash +# 인증된 Warp 요청에서 복사한 bearer 토큰 또는 Cookie 헤더 저장 +tokscale warp login + +# 자격 증명/캐시 상태 및 진단 확인 +tokscale warp status + +# 집계된 요청 수와 비용을 tokscale 로컬 캐시에 동기화 +tokscale warp sync + +# 저장된 자격 증명 삭제; --purge-cache를 추가하면 동기화된 사용량도 삭제 +tokscale warp logout --purge-cache +``` + +**캐시 위치**: `~/.config/tokscale/warp-cache/usage.json` + +**동작 방식**: `tokscale warp sync`는 Warp의 인증된 GraphQL API를 호출하여 계정 및 워크스페이스 집계 카운터를 가져옵니다. Tokscale은 요청 수를 메시지 카운트로, 벤더가 보고한 비용을 그대로 보존하지만, 요청 수를 합성 토큰으로 변환하지는 않습니다. Warp는 공개 리더보드가 토큰 기반 사용량만 수용하므로 기본 `submit` 데이터에서 제외됩니다. + +### 작업 기반 리포트 + +`report` 명령어는 작업 기반 사용량 분석을 생성합니다. LLM을 사용해 각 세션을 짧은 제목과 카테고리로 요약한 뒤, 관련 세션들을 상위 수준의 작업 클러스터로 묶어 토큰이 어디에 쓰였는지 한눈에 볼 수 있게 해 줍니다. + +```bash +# 기본 리포트 (오늘, 기본 Apple FM 요약기) +tokscale report + +# 최근 7일 +tokscale report --week + +# Claude Code를 요약 백엔드로 사용 +tokscale report --week --summarizer claude + +# Codex, Gemini 또는 Kiro 사용 +tokscale report --summarizer codex +tokscale report --summarizer gemini +tokscale report --summarizer kiro + +# LLM 요약 건너뛰기 (원본 데이터만 표시) +tokscale report --no-summarize + +# 처음부터 다시 요약 (범위 내 캐시된 요약 초기화) +tokscale report --week --rebuild + +# JSON으로 출력 +tokscale report --week --json + +# 워크스페이스 또는 클라이언트로 필터 +tokscale report --workspace my-project --client opencode +``` + +**요약 백엔드:** + +| 백엔드 | 명령어 | 비고 | +|---------|---------|-------| +| `apple-fm` | (기본값) | 네이티브 Rust FFI를 통한 온디바이스 Apple Foundation Models (Python 불필요). 사전 빌드된 Apple Silicon(macOS arm64) 바이너리에 기본 포함되어 있으며, Apple Intelligence가 켜진 macOS 26 이상에서 동작합니다. 그 외 환경(Intel Mac, 이전 macOS, Linux, Windows)에서는 내장 Rust 휴리스틱으로 투명하게 폴백하므로 기본값은 모든 플랫폼에서 동작합니다. | +| `claude` | `claude -p` | Claude Code CLI가 설치되어 인증되어 있어야 함. | +| `codex` | `codex --quiet` | Codex CLI가 설치되어 인증되어 있어야 함. | +| `gemini` | `gemini -p` | Gemini CLI가 설치되어 인증되어 있어야 함. | +| `kiro` | `kiro --non-interactive` | Kiro CLI가 설치되어 인증되어 있어야 함. | + +**동작 방식:** + +1. 세션을 스캔하여 로컬 SQLite 위키 데이터베이스(`wiki.db`, 플랫폼 설정 디렉터리 — Linux: `~/.config/tokscale/`, macOS: `~/Library/Application Support/tokscale/`)에 삽입합니다 +2. 요약되지 않은 세션을 선택한 LLM 백엔드에 배치 단위로 보내면, 각 세션에 대해 제목, 카테고리, 설명, 복잡도를 반환합니다 +3. 두 번째 LLM 패스에서 제목이 붙은 모든 세션을 3~8개의 상위 수준 작업 클러스터로 묶습니다 (예: "Kiro Auth", "Tokscale Report", "System Config") +4. 결과는 위키 DB에 캐시되며, 이후 실행 시 이미 요약된 세션은 건너뜁니다 + +요약은 기본적으로 활성화되어 있으며 기본 백엔드는 `apple-fm`(네이티브 Rust를 통한 Apple Foundation Models 온디바이스 추론, Python 불필요)입니다. `--no-summarize`로 요약을 끌 수 있습니다. + +**예시 출력:** + +``` + Task Group Sess Tokens Cost + ─────────────────────────────────────────────────────────────────────── + Tokscale Development 19 4.2B $22.66 + Add task-attributed report command + Implement wiki DB schema + Fix pricing lookup for new models + System Config 28 2.1B $10.06 + Configure OpenCode workspace settings + Update shell aliases + Kiro Auth 4 890.5M $3.10 + Implement JWT refresh flow +``` + +### 구독 사용량 + +Tokscale은 여러 AI 프로바이더에 걸친 실시간 구독 할당량을 가져와 표시할 수 있습니다. 이를 통해 플랜을 얼마나 사용했는지와 한도가 언제 초기화되는지 확인할 수 있습니다. + +```bash +# 감지된 모든 프로바이더의 구독 사용량 표시 +tokscale usage + +# JSON으로 출력 (스크립팅용) +tokscale usage --json + +# 가벼운 터미널 출력 (TUI 없음) +tokscale usage --light +``` + +TUI에서는 **Usage** 탭으로 이동해 구독 데이터를 확인하세요. `[Refresh]`로 구독 할당량을 새로고침할 수 있습니다. 키보드 새로고침 단축키 `r`도 동일한 새로고침 경로를 사용합니다. + +> **참고**: 구독 할당량과 잔액은 **벤더가 보고한 값**입니다 — tokscale은 각 프로바이더의 자체 할당량 엔드포인트를 호출하고 그 응답을 그대로 표시합니다. 표시되는 수치는 프로바이더가 보고하는 값(공식 대시보드에 나타나는 값과 동일)이며, tokscale 자체 사용량 추적과 독립적으로 검증되지 않습니다. + +#### 지원 프로바이더 + +| 프로바이더 | 인증 방식 | 지표 | 설정 | +|----------|-------------|---------|-------| +| **Claude** | OAuth (자격 증명 파일 또는 macOS Keychain) | 세션(5시간), 주간, Opus 할당량 | `claude`를 실행해 로그인 | +| **Codex** (OpenAI) | OAuth (`~/.config/codex/auth.json`, `~/.codex/auth.json`, 또는 저장된 Tokscale 계정) | 세션, 주간 할당량 | TUI Usage 탭에서 `[Add Codex]`를 사용하거나, `codex`를 실행해 로그인하거나, `tokscale codex import --name work`로 기존 인증을 가져오기 | +| **Z.ai** | API 키 (환경 변수) | 토큰 한도, 웹 검색 | `ZAI_API_KEY` 또는 `GLM_API_KEY` 설정 | +| **Amp** | API 키 (`~/.local/share/amp/secrets.json`) | 무료 티어 잔액, 크레딧 | `amp`를 실행해 로그인 | +| **GitHub Copilot** | GitHub 토큰 (keychain 또는 `~/.config/gh/hosts.yml`) | 프리미엄 상호작용, 채팅 할당량 | `gh auth login` 실행 | +| **Grok Build** | OAuth (`~/.grok/auth.json`) | 크레딧, 구독 플랜 | `grok login` 실행 | +| **Kimi** | OAuth (`~/.kimi/credentials/kimi-code.json`) | 세션, 주간 할당량 | `kimi`를 실행해 로그인 | +| **MiniMax** | API 키 (환경 변수) | 모델별 프롬프트 할당량 | `MINIMAX_API_KEY` 또는 `MINIMAX_API_TOKEN` 설정 | +| **MiniMax Token Plan** | API 키 (환경 변수) | 구간 + 주간 잔여 비율 할당량 (지역별: CN minimaxi.com + Global minimax.io) | `MINIMAX_TOKEN_PLAN_CN_KEY` 및/또는 `MINIMAX_TOKEN_PLAN_GLOBAL_KEY` 설정 | +| **Sakana** (Fugu) | 세션 쿠키 (환경 변수 또는 파일) — 빌링 콘솔 HTML 스크레이프, 공개 API 없음 | 5시간, 주간 할당량 창 (플랜 티어 + 월 가격은 메타데이터) | `SAKANA_SESSION_COOKIE` 설정 ([docs/providers/sakana.md](docs/providers/sakana.md) 참조) | + +프로바이더는 자동 감지됩니다 — 유효한 자격 증명이 있는 프로바이더만 표시됩니다. 프로바이더가 보이지 않으면 로그인했는지 또는 필요한 환경 변수를 설정했는지 확인하세요. + +#### Codex 다중 계정 사용량 + +Tokscale은 구독 사용량 표시를 위해 여러 Codex OAuth 계정을 저장할 수 있습니다. TUI Usage 탭은 저장된 계정들을 하나의 **Codex** 섹션 아래에 묶습니다. 활성 계정은 `*`로 표시되고, 비활성 계정은 `[Use]`로 선택할 수 있으며, 계정 삭제는 `[Remove]` 후 `[Confirm]`으로 진행합니다. + +TUI를 벗어나지 않고 계정을 추가하려면 Usage 탭에서 `[Add Codex]`를 클릭하세요. Tokscale은 임시 `CODEX_HOME`으로 `codex login`을 시작하고, 로그인 출력을 Usage 탭에 표시한 뒤, 결과 인증을 Tokscale의 저장 계정 스토어로 가져오고, 사용량을 새로고침합니다. 이렇게 하면 로그인이 격리되며 현재 Codex 인증을 전환하지 않습니다. Tokscale이 실제 Codex 인증 파일에 특정 계정을 쓰게 하려면 저장된 계정에서 `[Use]`를 클릭하세요. + +스크립트 기반 또는 수동 계정 관리를 위한 CLI 명령도 계속 제공됩니다: + +```bash +# 현재 Codex 인증을 이름 있는 Tokscale 계정으로 저장 +tokscale codex import --name work + +# 저장된 Codex 계정 목록 +tokscale codex accounts +tokscale codex accounts --json + +# 활성 Codex 계정 전환 및 Codex auth.json 기록 +tokscale codex switch work + +# 저장된 Codex 계정 추적 중지 (Tokscale 스토어에서만 제거 — +# codex CLI 자체의 auth.json/로그인은 절대 건드리지 않음) +tokscale codex remove personal + +# 활성 또는 이름 있는 계정의 구독 사용량 확인 +tokscale codex status +tokscale codex status --name personal --json +``` + +저장된 Codex 계정이 있으면 `tokscale usage --json`은 각 Codex 항목에 대한 구조화된 계정 메타데이터를 포함하며 TUI는 해당 항목들을 하나의 Codex 그룹 아래에 표시합니다. 저장된 계정이 없으면 Tokscale은 현재 Codex 인증 탐색 경로(`CODEX_HOME/auth.json`, `~/.config/codex/auth.json`, `~/.codex/auth.json`, 그리고 macOS Keychain)로 폴백합니다. + +#### 예시 출력 + +``` +╭──────────────────────────────────────────────────────────╮ +│ Session 85% left [=========---] resets in 2h 15m │ +│ Weekly 72% left [========----] resets Fri 3pm │ +│ Plan Max 20x │ +╰──────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ Session 40% left [=====-------] resets in 4h 30m │ +│ Weekly 90% left [==========--] resets Mon 12am │ +│ Account user@example.com │ +│ Plan Pro │ +╰──────────────────────────────────────────────────────────╯ +``` + ### 예시 출력 (`--light` 버전) CLI Light @@ -525,13 +833,25 @@ Tokscale은 설정을 `~/.config/tokscale/settings.json`에 저장합니다: { "colorPalette": "blue", "includeUnusedModels": false, - "defaultClients": ["opencode", "claude"] + "defaultClients": ["opencode", "claude"], + "scanner": { + "extraScanPaths": { + "codex": [ + "/Users/me/workspace/project-a/.codex/sessions", + "/Users/me/workspace/project-b/.codex/archived_sessions" + ], + "hermes": [ + "/Users/me/.hermes/profiles/director_planning", + "/Users/me/.hermes/profiles/research/state.db" + ] + } + } } ``` | 설정 | 타입 | 기본값 | 설명 | |---------|------|---------|-------------| -| `colorPalette` | string | `"blue"` | TUI 색상 테마 (green, halloween, teal, blue, pink, purple, orange, monochrome, ylgnbu) | +| `colorPalette` | string | `"blue"` | TUI 색상 테마 (green, halloween, teal, blue, pink, purple, orange, monochrome, ylgnbu, graphite, lagoon, dusk) | | `includeUnusedModels` | boolean | `false` | 리포트에서 제로 토큰 모델 표시 | | `autoRefreshEnabled` | boolean | `false` | TUI 자동 새로고침 활성화 | | `autoRefreshMs` | number | `60000` | 자동 새로고침 간격 (30000-3600000ms) | @@ -539,6 +859,11 @@ Tokscale은 설정을 `~/.config/tokscale/settings.json`에 저장합니다: | `defaultClients` | string[] | `[]` | `--client/-c` 플래그를 전달하지 않을 때 적용되는 기본 클라이언트 필터. `--client`와 동일한 ID를 받습니다 (예: `["opencode", "claude", "synthetic"]`). 알 수 없는 ID는 자동으로 무시됩니다. CLI 플래그가 있으면 이 목록은 완전히 무시됩니다 — 병합되지 않습니다. | | `light.writeCache` | boolean | `false` | `true`이면 `tokscale --light`가 렌더링 직후 TUI 캐시를 원자적으로 덮어씁니다. CLI 플래그 `--write-cache` / `--no-write-cache`가 실행별로 우선합니다. | | `minutelyTabEnabled` | boolean | `false` | TUI에 분 단위 Minutely 탭을 표시하고 데이터 로딩 중에 분 단위 집계를 수행합니다. 대부분의 사용자에게 분 단위 세분화는 틈새/진단 뷰이며, 대규모 데이터셋에서는 분 단위 버케팅에 무시할 수 없는 비용이 들기 때문에 기본적으로 비활성화되어 있습니다. | +| `scanner.extraScanPaths` | object | `{}` | Tokscale의 기본 home-root 위치 밖에 있는 세션을 위한 클라이언트별 추가 스캔 루트 | + +`scanner.extraScanPaths`는 프로젝트 단위 `.codex` 디렉터리나 가져온 Gemini/OpenClaw 히스토리 같은 영구적인 추가 루트에 사용하세요. Tokscale은 `$HERMES_HOME/profiles/*/state.db` 아래의 Hermes 프로필 데이터베이스를 자동으로 발견합니다(`HERMES_HOME`이 없으면 `~/.hermes/profiles/*/state.db`). 비표준 Hermes 프로필 위치에만 `scanner.extraScanPaths.hermes`를 사용하세요. Hermes 항목은 `state.db`를 포함하는 프로필 디렉터리를 가리키거나 `state.db` 파일을 직접 가리킬 수 있습니다. Tokscale은 매 실행마다 이 경로들을 기본 스캔 루트와 병합하고, 겹치는 루트는 정규 경로(canonical path) 기준으로 중복 제거합니다. + +`defaultClients`로 개인 기본값을 고정할 수 있습니다 — 예를 들어 OpenCode와 Claude만 사용한다면 `["opencode", "claude"]`로 설정하면, `tokscale`(플래그 없이)은 모든 리포트를 자동으로 해당 클라이언트로 범위를 좁힙니다. 단일 실행에 대해 재정의하려면 명령줄에서 `--client`를 전달하세요. #### Minutely 탭 활성화 @@ -559,7 +884,7 @@ Minutely 탭은 토큰 사용량을 분 단위로 표시하며, 버스트 패턴 재생성 가능한 CLI/TUI/가격/Wrapped 캐시는 `~/.config/tokscale/cache/` 아래에 저장됩니다 (`TOKSCALE_CONFIG_DIR`를 설정한 경우 `${TOKSCALE_CONFIG_DIR}/cache/`). 통합 동기화 아티팩트는 `~/.config/tokscale/antigravity-cache/` 및 `~/.config/tokscale/trae-cache/` 같은 클라이언트별 캐시 루트에 저장됩니다: - `tui-data-cache.json` — TUI 시작 캐시 -- `source-message-cache.bin` + `source-message-cache.lock` — 소스 메시지 캐시와 락 파일 +- `source-message-cache-v2/` + `source-message-cache.lock` — 샤딩된 소스 메시지 캐시와 락 파일 - `pricing-litellm.json` / `pricing-openrouter.json` — 가격 캐시 - `opencode-migration.json` — OpenCode 마이그레이션 기록 - `fonts/`, `images/` — Wrapped 에셋 캐시 @@ -573,14 +898,23 @@ Minutely 탭은 토큰 사용량을 분 단위로 표시하며, 버스트 패턴 | 변수 | 기본값 | 설명 | |----------|---------|-------------| | `TOKSCALE_NATIVE_TIMEOUT_MS` | `300000` (5분) | `nativeTimeoutMs` 설정 오버라이드 | +| `TOKSCALE_API_TOKEN` | unset | 비대화형 `submit` 및 `delete-submitted-data` 실행을 위한 Tokscale 개인 API 토큰. Settings > API Tokens에서 생성하거나 `tokscale login --token tt_xxx`로 로컬에 저장하세요. | +| `TOKSCALE_EXTRA_DIRS` | unset | 일회성 추가 세션 루트, `client:/abs/path,client:/abs/path` 형식 | | `TOKSCALE_CONFIG_DIR` | unset | 설정 디렉토리 루트(`settings.json`, `star-cache.json`, `cache/`, `antigravity-cache/`, `trae-cache/` 위치)를 오버라이드합니다. 절대 경로 권장; 상대 경로는 프로세스 CWD 기준으로 해석됩니다. CI 샌드박스나 비기본 위치를 고정할 때 유용합니다. 설정되면 tokscale은 macOS 레거시 경로(`~/Library/Application Support/tokscale/`)로 폴백하지 않습니다. | +| `TOKSCALE_FM_DEBUG` | unset | 설정되면 Apple Foundation Models 진단 정보(macOS 버전 게이트, dlopen dylib 경로, 로드/심볼 오류)를 stderr로 출력하여 온디바이스 apple-fm이 동작했는지 또는 동작하지 않았는지 이유를 설명합니다. | ```bash # 예시: 매우 큰 데이터셋에 대한 타임아웃 증가 TOKSCALE_NATIVE_TIMEOUT_MS=600000 tokscale graph --output data.json + +# 예시: 일회성 추가 스캔 루트 +TOKSCALE_EXTRA_DIRS='codex:/Users/me/workspace/project-a/.codex/sessions,gemini:/Users/me/imports/imac/gemini/tmp' tokscale + +# 예시: 대화형 브라우저 로그인 없이 CI에서 제출 +TOKSCALE_API_TOKEN=tt_xxx tokscale submit ``` -> **참고**: 영구적인 변경은 `~/.config/tokscale/settings.json`에서 `nativeTimeoutMs`를 설정하는 것을 권장합니다. 환경 변수는 일회성 오버라이드나 CI/CD에 적합합니다. +> **참고**: 영구적인 추가 루트는 `~/.config/tokscale/settings.json`의 `scanner.extraScanPaths`를 권장합니다. `TOKSCALE_EXTRA_DIRS`는 일회성 오버라이드나 CI/CD에 가장 적합합니다. ### Headless 모드 @@ -656,7 +990,7 @@ tokscale sources --json - **인터랙티브 툴팁**: 호버 시 상세 일별 분석 표시 - **일별 분석 패널**: 클릭하여 소스별, 모델별 세부사항 확인 - **연도 필터링**: 연도 간 탐색 -- **소스 필터링**: 플랫폼별 필터 (OpenCode, Claude, Codex, Copilot, Cursor, Gemini, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi, Qwen, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Zed, Kiro, Trae, Gajae-Code, Synthetic) +- **소스 필터링**: 플랫폼별 필터 (OpenCode, Claude, Codex, Copilot, Cursor, Gemini, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi, Qwen, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Antigravity CLI, Zed, Kiro, Trae, Warp, Cline, Gajae-Code, Grok Build, Jcode, MiMo Code, Command Code, Junie, ZCode, Synthetic) - **통계 패널**: 총 비용, 토큰, 활동 일수, 연속 기록 - **FOUC 방지**: React 하이드레이션 전 테마 적용 (깜빡임 없음) @@ -690,13 +1024,29 @@ GitHub 프로필 README에 Tokscale 공개 통계를 직접 임베드할 수 있 [![Tokscale Stats](https://tokscale.ai/api/embed//svg)](https://tokscale.ai/u/) ``` -- ``을 GitHub 사용자명으로 교체하세요 -- 선택적 쿼리 파라미터: - - `theme=light` 라이트 테마 사용 - - `sort=tokens` (기본값) 또는 `sort=cost` 랭킹 기준 제어 - - `compact=1` 컴팩트 레이아웃 + 축약 숫자 표기법 사용 (예: `1.2M`, `$3.4K`) -- 예시: - - `https://tokscale.ai/api/embed//svg?theme=light&sort=cost&compact=1` +``을 GitHub 사용자명으로 교체하세요. 쿼리 파라미터가 없으면 +기본 `classic` 카드가 렌더링됩니다. 디자인을 커스터마이즈하려면 아래 +파라미터를 덧붙이세요. + +| 파라미터 | 값 | 효과 | +| --- | --- | --- | +| `template` | `classic` (기본값) · `minimal` · `terminal` · `graph` · `orbit` · `vitals` · `blueprint` · `receipt` | 카드 디자인 | +| `color` | `blue` · `green` · `teal` · `purple` · `pink` · `orange` · `monochrome` · `halloween` · `YlGnBu` | 강조 색상 및 기여 그래프 팔레트 | +| `theme` | `dark` (기본값) · `light` | 라이트 또는 다크 카드 | +| `sort` | `tokens` (기본값) · `cost` | 랭크를 가져올 리더보드 기준 | +| `tokens`, `cost` | `compact` · `full` | 숫자 형식, 독립적으로 설정 — `20.9B` vs `20,941,000,000` | +| `rank` | `plain` (기본값, `#134`) · `percent` (`top 12%`) · `total` (`#134 / 1,174`) | 리더보드 랭크 표시 방식 | +| `graph` | `1`로 기여 그래프 추가 (기본값은 꺼짐) | `classic`, `minimal`, `terminal`, `orbit`, `blueprint`, `receipt`에서 지원 | +| `compact` | `1`로 컴팩트 레이아웃 사용 | `classic` 전용 | + +예시: + +```md +![](https://tokscale.ai/api/embed//svg?template=minimal&color=purple&graph=1) +![](https://tokscale.ai/api/embed//svg?template=orbit&color=pink&rank=percent) +![](https://tokscale.ai/api/embed//svg?template=terminal&color=green&theme=light) +![](https://tokscale.ai/api/embed//svg?template=receipt&color=YlGnBu&graph=1) +``` ### GitHub 프로필 뱃지 @@ -915,16 +1265,18 @@ cd packages/core && bun run bench ### 네이티브 모듈 대상 -| 플랫폼 | 아키텍처 | 상태 | -|----------|--------------|--------| -| macOS | x86_64 | ✅ 지원 | -| macOS | aarch64 (Apple Silicon) | ✅ 지원 | -| Linux | x86_64 (glibc) | ✅ 지원 | -| Linux | aarch64 (glibc) | ✅ 지원 | -| Linux | x86_64 (musl) | ✅ 지원 | -| Linux | aarch64 (musl) | ✅ 지원 | -| Windows | x86_64 | ✅ 지원 | -| Windows | aarch64 | ✅ 지원 | +| 플랫폼 | 아키텍처 | +|----------|--------------| +| macOS | x86_64 | +| macOS | aarch64 (Apple Silicon) | +| Linux | x86_64 (glibc) | +| Linux | aarch64 (glibc) | +| Linux | x86_64 (musl) | +| Linux | aarch64 (musl) | +| Windows | x86_64 | +| Windows | aarch64 | + +Linux에서는 런처가 glibc와 musl을 자동으로 감지합니다 (`process.report`, `/lib/ld-musl-*.so.1`의 musl 동적 로더, 그리고 `ldd`를 통해). 감지가 잘못된 종류를 선택하는 경우 — 예를 들어 최소 컨테이너에서 — `TOKSCALE_LIBC=musl` (또는 `TOKSCALE_LIBC=gnu`)를 설정해 강제할 수 있습니다. ### Windows 지원 @@ -957,17 +1309,30 @@ AI 코딩 도구들은 크로스 플랫폼 위치에 세션 데이터를 저장 | Droid | `~/.factory/` | `%USERPROFILE%\.factory\` | 모든 플랫폼에서 동일한 경로 | | Pi | `~/.pi/` and `~/.omp/` | `%USERPROFILE%\.pi\` and `%USERPROFILE%\.omp\` | 모든 플랫폼에서 동일한 경로 (Pi 및 [Oh My Pi](https://github.com/can1357/oh-my-pi) 모두 지원) | | Kimi CLI | `~/.kimi/` | `%USERPROFILE%\.kimi\` | 모든 플랫폼에서 동일한 경로 | +| Kimi Code | `~/.kimi-code/` | `%USERPROFILE%\.kimi-code\` | 모든 플랫폼에서 동일한 경로 | | Qwen CLI | `~/.qwen/` | `%USERPROFILE%\.qwen\` | 모든 플랫폼에서 동일한 경로 | | Roo Code | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\rooveterinaryinc.roo-cline\tasks\` | VS Code globalStorage 작업 로그 | | Kilo | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\kilocode.kilo-code\tasks\` | VS Code globalStorage 작업 로그 | +| Cline | Linux: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/`; macOS: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/`; 서버: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/` | `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\tasks\` | VS Code globalStorage 작업 로그 | | Mux | `~/.mux/sessions/` | `%USERPROFILE%\.mux\sessions\` | 모든 플랫폼에서 동일한 경로 | | Codebuff | `~/.config/manicode/projects/` (+ `manicode-dev`, `manicode-staging`) | `%USERPROFILE%\.config\manicode\projects\` | `CODEBUFF_DATA_DIR` 환경변수로 오버라이드 | | Kilo CLI | `~/.local/share/kilo/` | `%USERPROFILE%\.local\share\kilo\` | OpenCode와 같이 `xdg-basedir` 사용 | | Crush | `$XDG_DATA_HOME/crush/` (기본값: `~/.local/share/crush/`) | `%USERPROFILE%\.local\share\crush\` (설정된 경우 `%XDG_DATA_HOME%\crush\`) | 기본 경로를 포함한 XDG 데이터 디렉토리 사용 | | Goose | `~/.local/share/goose/sessions/` (+ macOS Application Support, 레거시 Block 경로) | `%USERPROFILE%\.local\share\goose\sessions\` | `GOOSE_PATH_ROOT` 환경변수로 설정 가능 | | Antigravity | `~/.config/tokscale/antigravity-cache/sessions/` | — | `tokscale antigravity sync`는 현재 macOS/Linux에서만 지원 | +| Zed Agent | `~/.local/share/zed/threads/threads.db` | `%LOCALAPPDATA%\Zed\threads\threads.db` | 호스팅된 Zed 모델 사용량 전용; 외부 ACP 에이전트는 포함되지 않음 | +| Kiro | `~/.kiro/sessions/cli/` 및 `~/.local/share/kiro-cli/data.sqlite3` | `%USERPROFILE%\.kiro\sessions\cli\` 및 `%USERPROFILE%\.local\share\kiro-cli\data.sqlite3` | Kiro 세션 파일과 함께 존재하는 경우 Kiro CLI SQLite 데이터베이스를 파싱 | | Trae | `~/.config/tokscale/trae-cache/sessions/` | `%APPDATA%\tokscale\trae-cache\sessions\` | `tokscale trae sync`로 한 번 동기화; 설치된 Trae IDE 또는 Trae Solo 데스크톱 앱에서 자격 증명 자동 발견 | +| Warp/Oz | `~/.config/tokscale/warp-cache/usage.json` | `%APPDATA%\tokscale\warp-cache\usage.json` | `tokscale warp sync`로 동기화; 집계된 요청 수와 비용만, 토큰 트랜스크립트 없음 | +| Grok Build | `~/.grok/sessions/` | `%USERPROFILE%\.grok\sessions\` | `GROK_HOME` 환경변수로 설정 가능; `updates.jsonl` 세션 업데이트 파싱 | +| Jcode | `~/.jcode/sessions/` | `%USERPROFILE%\.jcode\sessions\` | `JCODE_HOME` 환경변수로 설정 가능; `session_*.json` 스냅샷과 `session_*.journal.jsonl` 사이드카 파싱 | +| MiMo Code | `~/.local/share/mimocode/` | `%USERPROFILE%\.local\share\mimocode\` | XDG 데이터 디렉토리 사용; SQLite 데이터베이스 `mimocode.db` | | Gajae-Code | `~/.gjc/agent/sessions/` | `%USERPROFILE%\.gjc\agent\sessions\` | `GJC_CODING_AGENT_DIR`로 설정 가능 (`GJC_CONFIG_DIR`/`PI_CONFIG_DIR`도 지원; Linux/macOS에서는 `$XDG_DATA_HOME/gjc/sessions/`도 확인) | +| Junie | `~/.junie/sessions/` | `%USERPROFILE%\.junie\sessions\` | 모든 플랫폼에서 동일한 home 상대 경로 사용; `events.jsonl` 사용 이벤트 파싱 | +| ZCode | `~/.zcode/cli/db/db.sqlite` 및 `~/.zcode/projects/` | `%USERPROFILE%\.zcode\cli\db\db.sqlite` 및 `%USERPROFILE%\.zcode\projects\` | v2 SQLite 모델 사용량과 레거시 `*.jsonl` 세션 트랜스크립트 파싱; Z.ai의 GLM 모델용 ADE | +| OpenCodeReview | `~/.opencodereview/sessions/` | `%USERPROFILE%\.opencodereview\sessions\` | `*.jsonl` 세션 트랜스크립트 파싱; Alibaba의 AI 코드 리뷰 도구 | +| CodeBuddy | `~/.codebuddy/projects/` + 확장 프로그램 로그 | `%USERPROFILE%\.codebuddy\projects\` + CodeBuddy / VS Code 확장 프로그램 로그 | CodeBuddy CLI, IDE, VS Code 플러그인 토큰 사용량 파싱 | +| WorkBuddy | `~/.workbuddy/projects/` + `~/.workbuddy/workbuddy.db` | `%USERPROFILE%\.workbuddy\projects\` + `%USERPROFILE%\.workbuddy\workbuddy.db` | WorkBuddy 토큰 사용량 파싱, 집계 SQLite 데이터베이스를 폴백으로 사용 | | Synthetic | 다른 소스에서 재귀속 | 다른 소스에서 재귀속 | `hf:` 모델 접두사 + `synthetic` provider 감지 | > **참고**: Windows에서 `~`는 `%USERPROFILE%`로 확장됩니다 (예: `C:\Users\사용자이름`). 이러한 도구들은 `%APPDATA%`와 같은 Windows 기본 경로 대신 크로스 플랫폼 일관성을 위해 의도적으로 Unix 스타일 경로(`.local/share` 등)를 사용합니다. @@ -1061,6 +1426,44 @@ OpenCode 1.2+는 세션을 SQLite에 저장합니다. Tokscale은 SQLite를 먼 OpenCode는 빌드된 릴리스 채널에 따라 DB 파일명을 결정합니다: `latest`, `beta` 채널은 `opencode.db`를 사용하고, 나머지 채널은 `opencode-.db` (예: `opencode-stable.db`, `opencode-nightly.db`)를 사용합니다. Tokscale은 모든 변형을 스캔하므로 여러 채널을 함께 사용하는 경우에도 통합된 뷰를 제공합니다. +`OPENCODE_DB`를 `~/.local/share/opencode` 밖의 파일로 지정해 opencode를 실행한 경우, tokscale이 매 실행마다 찾을 수 있도록 `~/.config/tokscale/settings.json`에 절대 경로를 추가하세요: + +```json +{ + "scanner": { + "opencodeDbPaths": [ + "/custom/location/opencode.db", + "/another/location/opencode-stable.db" + ] + } +} +``` + +경로는 자동 발견과 병합되고, 정규 경로 기준으로 중복 제거되며, 존재하지 않는 항목은 조용히 건너뜁니다 (오래된 설정이 스캔을 깨뜨리지 않도록). `opencode.db-wal`, `opencode.db-shm` 및 기타 SQLite 사이드카는 거부됩니다. + +Tokscale의 기본 home-root 위치 밖에 세션을 보관하는 경우, 클라이언트별 추가 스캔 루트를 영구적으로 지정할 수도 있습니다: + +```json +{ + "scanner": { + "extraScanPaths": { + "codex": [ + "/Users/me/workspace/project-a/.codex/sessions", + "/Users/me/workspace/project-b/.codex/archived_sessions" + ], + "gemini": ["/Users/me/imports/imac/gemini/tmp"], + "hermes": [ + "/Users/me/.hermes/profiles/director_planning", + "/Users/me/.hermes/profiles/research/state.db" + ], + "openclaw": ["/Users/me/imports/imac/openclaw/agents"] + } + } +} +``` + +이는 프로젝트 단위 `.codex` 디렉터리, 가져온 히스토리, 그리고 기본 `$HERMES_HOME/state.db`나 `~/.hermes/state.db` 위치 밖의 Hermes 프로필 데이터베이스에 유용합니다. Tokscale은 여전히 기본 루트를 스캔한 다음, 그 위에 `scanner.extraScanPaths`와 `TOKSCALE_EXTRA_DIRS`를 정규 경로 중복 제거와 함께 병합합니다. 워크스페이스 전체를 자동 발견하지는 않습니다. + 각 메시지 포함 내용: ```json { @@ -1080,13 +1483,17 @@ OpenCode는 빌드된 릴리스 채널에 따라 DB 파일명을 결정합니다 ### Claude Code -위치: `~/.claude/projects/{projectPath}/*.jsonl` +위치: `~/.claude/projects/{projectPath}/*.jsonl` 및 `~/.claude/transcripts/*.jsonl` 어시스턴트 메시지의 사용량 데이터를 포함하는 JSONL 형식: ```json {"type": "assistant", "message": {"model": "claude-sonnet-4-20250514", "usage": {"input_tokens": 1234, "output_tokens": 567, "cache_read_input_tokens": 890}}, "timestamp": "2024-01-01T00:00:00Z"} ``` +`~/.claude/transcripts/` 아래의 래퍼 트랜스크립트 파일은 실제 Claude 사용량 메타데이터를 포함할 때만 계산됩니다. 사용자/도구 이벤트는 있지만 `usage` 블록이 없는 파일은 추정하지 않고 건너뜁니다. + +Tokscale의 `claude` 클라이언트는 Claude Code 토큰 회계이며, Claude Desktop 채팅 회계가 아닙니다. Claude Desktop은 `~/Library/Application Support/Claude` 같은 위치에 앱 데이터를 저장하지만, Anthropic은 소비자용 데스크톱 채팅이나 채팅 기록 내보내기에 대한 안정적인 로컬 메시지별 토큰 원장을 문서화하지 않습니다. Claude Desktop 데이터는 존재하지만 Claude Code JSONL 루트만 스캔 가능한 경우 `tokscale clients`를 실행하면 진단 정보를 볼 수 있습니다. `tokscale usage`는 Claude Code 자격 증명으로부터 best-effort Claude 구독 할당량 막대를 표시할 수 있는 반면, 조직/API 사용량은 Anthropic의 Admin Usage and Cost API에 속하며 로컬 트랜스크립트 스캔과는 의도적으로 분리되어 있습니다. + ### Codex CLI 위치: `~/.codex/sessions/*.jsonl` @@ -1145,9 +1552,9 @@ Tokscale은 `chat` span을 토큰 집계의 출처로 취급하고, 도구 span ### Cursor IDE -위치: `~/.config/tokscale/cursor-cache/` (Cursor API를 통해 동기화) +위치: `~/.config/tokscale/cursor-cache/usage*.csv` (Cursor API를 통해 동기화) -Cursor 데이터는 세션 토큰을 사용하여 Cursor API에서 가져와 로컬에 캐시됩니다. 인증하려면 `tokscale cursor login`을 실행하세요. 설정 안내는 [Cursor IDE 명령어](#cursor-ide-명령어)를 참조하세요. +Cursor 데이터는 세션 토큰을 사용하여 Cursor API에서 가져와 로컬에 캐시됩니다. Tokscale은 리포트를 위해 해당 캐시 파일을 읽으며, 로컬 `~/.cursor` 세션 데이터는 파싱하지 않습니다. 설정 안내는 [Cursor IDE 명령어](#cursor-ide-명령어)를 참조하세요. ### Antigravity @@ -1161,6 +1568,24 @@ Antigravity 데이터는 루트 명령에서 자동으로 가져오지 않습니 Trae 데이터는 루트 명령에서 자동으로 가져오지 않습니다. 먼저 `tokscale trae login`을 실행한 뒤, 리포트 전에 `tokscale trae sync` 또는 `tokscale trae sync --since 30`을 실행하세요. Tokscale은 동기화된 API dump를 세션 수준 레코드로 파싱하고 Trae가 반환한 비용 합계를 보존합니다. +### Warp/Oz + +위치: `~/.config/tokscale/warp-cache/usage.json` (인증된 GraphQL API를 통해 동기화) + +Warp/Oz 데이터는 루트 명령으로 자동으로 가져오지 않습니다. 리포트 전에 `tokscale warp login`을 실행한 다음 `tokscale warp sync`를 실행하세요. Warp는 토큰에 귀속된 로컬 트랜스크립트를 노출하지 않으므로, Tokscale은 집계된 요청 수와 지출만 기록합니다. + +### Grok Build + +위치: `$GROK_HOME/sessions/*/*/updates.jsonl` (폴백: `~/.grok/sessions/*/*/updates.jsonl`) + +Grok Build 데이터는 로컬 세션 업데이트에서 직접 파싱됩니다. 현재 로그는 안정적인 input/output 분리 없이 누적 `totalTokens` 카운터를 노출하므로, Tokscale은 턴별 양수 증가분을 input 토큰으로 기록합니다. `grok-composer-2.5-fast`는 전용 공개 가격이 생길 때까지 Composer 2.5 Fast 가격 override에 임시 매핑됩니다. + +### Jcode + +위치: `$JCODE_HOME/sessions/session_*.json` (폴백: `~/.jcode/sessions/session_*.json`) 및 매칭되는 `session_*.journal.jsonl` 사이드카. + +Jcode 데이터는 로컬 세션 스냅샷에서 직접 파싱됩니다. Tokscale은 다른 클라이언트 신원을 위장하지 않고 어시스턴트의 `messages[].token_usage` 필드(`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, `reasoning_output_tokens`)를 읽습니다. 매칭되는 저널 사이드카는 중복 제거 전에 동일한 세션 스트림으로 병합되므로, Jcode가 스냅샷에 체크포인트로 반영하기 전까지 최근에 추가된 메시지도 포함됩니다. 재생(replay) 중복 제거에는 안정적인 메시지 ID를 사용하며, ID가 없는 잘못된/커스텀 레코드는 범위가 한정된 폴백 키를 사용합니다. + ### OpenClaw 위치: `~/.openclaw/agents/*/sessions/sessions.json` (레거시 경로도 스캔: `~/.clawdbot/`, `~/.moltbot/`, `~/.moldbot/`) @@ -1183,7 +1608,7 @@ model_change 이벤트와 어시스턴트 메시지가 포함된 세션 JSONL ### Hermes Agent -위치: `$HERMES_HOME/state.db` (폴백: `~/.hermes/state.db`) +위치: `$HERMES_HOME/state.db` (폴백: `~/.hermes/state.db`) 및 표준 프로필 데이터베이스 `$HERMES_HOME/profiles/*/state.db` (`HERMES_HOME`이 활성 프로필을 가리키는 경우 형제 `~/.hermes/profiles/*/state.db`) Hermes는 세션 수준 사용량을 SQLite `sessions` 테이블에 저장합니다. Tokscale은 `model`이 존재하고 토큰 또는 비용 합계가 0이 아닌 행을 가져오며, `started_at`을 타임스탬프로 사용하고, `message_count`를 보존하며, `actual_cost_usd`를 `estimated_cost_usd`보다 우선합니다. @@ -1207,6 +1632,13 @@ StatusUpdate 메시지를 포함하는 wire.jsonl 형식: {"timestamp": 1770983426.420942, "message": {"type": "StatusUpdate", "payload": {"token_usage": {"input_other": 1562, "output": 2463, "input_cache_read": 0, "input_cache_creation": 0}, "message_id": "chatcmpl-xxx"}}} ``` +### Kimi Code + +위치: `~/.kimi-code/sessions/{WORKDIR}/{SESSION_UUID}/agents/{AGENT}/wire.jsonl` +```json +{"type":"usage.record","model":"kimi-code/kimi-for-coding","usage":{"inputOther":1163,"output":352,"inputCacheRead":22272,"inputCacheCreation":0},"usageScope":"turn","time":1780410897480} +``` + ### Qwen CLI 위치: `~/.qwen/projects/{PROJECT_PATH}/chats/{CHAT_ID}.jsonl` @@ -1252,6 +1684,19 @@ Kilo는 Roo Code와 동일한 작업 로그 형식을 사용합니다. Tokscale - `text` JSON에서 `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, `cost`, `apiProtocol` 파싱 - 사용 가능한 경우 인접한 `api_conversation_history.json`에서 모델/에이전트 메타데이터 보강 +### Cline + +위치: +- Linux 데스크톱 VS Code: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` +- macOS 데스크톱 VS Code: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` +- Windows 데스크톱 VS Code: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\tasks\{TASK_ID}\ui_messages.json` +- 서버 (최선 노력): `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` + +Cline은 Roo Code와 Kilo가 포크한 원본(upstream) 프로젝트로, 동일한 VS Code globalStorage 작업 로그 형식을 사용합니다. Tokscale은 동일한 규칙을 적용합니다: +- `ui_messages.json`에서 `say/api_req_started` 이벤트만 계산 +- `text` JSON에서 `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, `cost`, `apiProtocol` 파싱 +- 사용 가능한 경우 인접한 `api_conversation_history.json`에서 모델/에이전트 메타데이터 보강 + ### Mux 위치: @@ -1303,6 +1748,41 @@ Synthetic은 기존 에이전트 세션을 후처리하여 재귀속합니다. ` 또한 `~/.local/share/octofriend/sqlite.db`를 감지해 토큰 정보가 있는 레코드를 파싱합니다. +### MiMo Code + +위치: `~/.local/share/mimocode/mimocode.db` (XDG 데이터 디렉토리) + +MiMo Code는 SQLite 데이터베이스에 세션 데이터를 저장합니다. Tokscale은 워크스페이스 컨텍스트를 위해 `session` 테이블과 조인된 `message` 테이블을 쿼리합니다: + +```sql +SELECT m.id, m.session_id, m.data, NULLIF(s.directory, '') AS workspace_root +FROM message m +LEFT JOIN session s ON s.id = m.session_id +WHERE json_extract(m.data, '$.role') = 'assistant' + AND json_extract(m.data, '$.tokens') IS NOT NULL +``` + +`data` 컬럼은 JSON 형식이며 다음 토큰 관련 필드를 포함합니다: +```json +{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "providerID": "anthropic", + "cost": 0.0032, + "tokens": { + "input": 1200, + "output": 450, + "reasoning": 0, + "cache": { "read": 800, "write": 0 } + }, + "time": { "created": 1780410897000, "completed": 1780410912000 }, + "agent": "micode", + "path": { "root": "/Users/me/project" } +} +``` + +Tokscale은 타임스탬프, 모델, 프로바이더, 토큰 수, 비용, 에이전트 이름의 지문을 사용하여 포크된 세션 간 메시지를 중복 제거합니다. + ## 가격 Tokscale은 [LiteLLM의 가격 데이터베이스](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)에서 실시간 가격을 가져옵니다. @@ -1311,6 +1791,8 @@ Tokscale은 [LiteLLM의 가격 데이터베이스](https://github.com/BerriAI/li **Cursor 모델 가격**: LiteLLM과 OpenRouter 모두에 없는 최신 모델(예: `gpt-5.3-codex`)은 [Cursor 모델 문서](https://cursor.com/en-US/docs/models)에서 가져온 하드코딩 가격을 사용합니다. 이 오버라이드는 모든 업스트림 소스 다음에, 퍼지 매칭 이전에 확인되므로 실제 업스트림 가격이 사용 가능해지면 자동으로 양보합니다. +**Sakana Fugu 가격**: Fugu Ultra 비용은 Sakana가 공개한 종량제(pay-as-you-go) 요율로 추정하며, `fugu` 라우터 모델은 실제로 오케스트레이션한 기반 모델의 가변 요율이 곧 그 비용이므로 의도적으로 가격을 책정하지 않습니다. + **캐싱**: 가격 데이터는 1시간 TTL로 디스크에 캐시되어 빠른 시작을 보장합니다: - LiteLLM 캐시: `~/.config/tokscale/cache/pricing-litellm.json` - OpenRouter 캐시: `~/.config/tokscale/cache/pricing-openrouter.json` (지원 제공자의 모델에 대한 작성자 가격 정보를 캐시) @@ -1321,7 +1803,7 @@ Tokscale은 [LiteLLM의 가격 데이터베이스](https://github.com/BerriAI/li - 캐시 읽기 토큰 (할인) - 캐시 쓰기 토큰 - 추론 토큰 (o1과 같은 모델용) -- 구간별 가격 (200k 토큰 이상) +- 모델별 구간 가격 (예: 200k 또는 272k 토큰 이상) ## 기여 diff --git a/README.md b/README.md index bb8c2ebc0..23cb7f126 100644 --- a/README.md +++ b/README.md @@ -53,36 +53,49 @@ **Tokscale** helps you monitor and analyze your token consumption from: -| Logo | Client | Data Location | Supported | -|------|----------|---------------|-----------| -| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+, all channels including `opencode-stable.db`) or/and `~/.local/share/opencode/storage/message/` (legacy/unmigrated) | ✅ Yes | -| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` and `~/.claude/transcripts/` | ✅ Yes | -| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ legacy: `.clawdbot`, `.moltbot`, `.moldbot`) | ✅ Yes | -| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | ✅ Yes | -| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | ✅ Yes | -| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db` (fallback: `~/.hermes/state.db`) | ✅ Yes | -| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json` (fallback: `~/.gemini/tmp/*/chats/*.json`) | ✅ Yes | -| Cursor | [Cursor IDE](https://cursor.com/) | Cursor API export cached at `~/.config/tokscale/cursor-cache/usage*.csv` (not `~/.cursor`) | ✅ Yes | -| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | ✅ Yes | -| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/` (+ `manicode-dev`, `manicode-staging`; override via `CODEBUFF_DATA_DIR`) | ✅ Yes | -| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | ✅ Yes | -| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | ✅ Yes | -| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | `~/.kimi/sessions/` | ✅ Yes | -| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | ✅ Yes | -| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | ✅ Yes | -| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | ✅ Yes | -| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | ✅ Yes | -| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | ✅ Yes | -| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json` (project registry; fallback: `~/.local/share/crush/projects.json`) | ✅ Yes | -| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db` (+ macOS Application Support, legacy Block/goose paths; override via `GOOSE_PATH_ROOT`) | ✅ Yes | -| Antigravity | [Google Antigravity](https://antigravity.google/) | Cached via `tokscale antigravity sync` to `~/.config/tokscale/antigravity-cache/sessions/*.jsonl` (live RPC against the local language server) | ✅ Yes | -| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo) (international) | Cached via `tokscale trae sync` to `~/.config/tokscale/trae-cache/sessions/*.json` (account-level usage from the official API) | ✅ Yes | -| Warp | [Warp](https://www.warp.dev/) / Oz | Cached via `tokscale warp sync` to `~/.config/tokscale/warp-cache/usage.json` (aggregate requests and spend only; no token transcripts) | ✅ Yes | -| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db` (macOS: `~/Library/Application Support/Zed/threads/threads.db`; Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`; hosted Zed models only, not external ACP agents) | ✅ Yes | -| Kiro | Kiro | `~/.kiro/sessions/cli/*.json` (+ `*.jsonl`) and `~/.local/share/kiro-cli/data.sqlite3` (macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`) | ✅ Yes | -| Cline | [Cline](https://github.com/cline/cline) | VS Code globalStorage tasks (Linux: `~/.config/Code/...`; macOS: `~/Library/Application Support/Code/...`; Windows: `%APPDATA%\Code\...`; server: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/`) | ✅ Yes | -| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/` (override via `GJC_CODING_AGENT_DIR`, `GJC_CONFIG_DIR`, `PI_CONFIG_DIR`; `$XDG_DATA_HOME/gjc/sessions/` on Linux/macOS) | ✅ Yes | -| Synthetic | [Synthetic](https://synthetic.new/) | Re-attributed from other sources via `hf:` model prefix or `synthetic` provider (+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | ✅ Yes | +| Logo | Client | Data Location | +|------|----------|---------------| +| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+, all channels including `opencode-stable.db`) or/and `~/.local/share/opencode/storage/message/` (legacy/unmigrated) | +| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` and `~/.claude/transcripts/` | +| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ legacy: `.clawdbot`, `.moltbot`, `.moldbot`) | +| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | +| Sakana Fugu | [Sakana Fugu](https://sakana.ai/fugu/) | via Codex — `~/.codex/sessions/*.jsonl` (`model_provider: sakana`) | +| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | +| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db` and `$HERMES_HOME/profiles/*/state.db` (fallback: `~/.hermes/...`) | +| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json` (fallback: `~/.gemini/tmp/*/chats/*.json`) | +| Cursor | [Cursor IDE](https://cursor.com/) | Cursor API export cached at `~/.config/tokscale/cursor-cache/usage*.csv` (not `~/.cursor`) | +| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | +| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/` (+ `manicode-dev`, `manicode-staging`; override via `CODEBUFF_DATA_DIR`) | +| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | +| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | +| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) / [Kimi Code](https://github.com/MoonshotAI/kimi-code) | kimi-cli: `~/.kimi/sessions/` kimi-code: `~/.kimi-code/sessions/` (override via `KIMI_CODE_HOME`) | +| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | +| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | +| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | +| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | +| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | +| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json` (project registry; fallback: `~/.local/share/crush/projects.json`) | +| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db` (+ macOS Application Support, legacy Block/goose paths; override via `GOOSE_PATH_ROOT`) | +| Antigravity | [Google Antigravity](https://antigravity.google/) | Cached via `tokscale antigravity sync` to `~/.config/tokscale/antigravity-cache/sessions/*.jsonl` (live RPC against the local language server) | +| Antigravity CLI | [Antigravity CLI](https://antigravity.google/) | `~/.gemini/antigravity-cli/conversations/*.db` (override the Gemini home via `GEMINI_CLI_HOME`; local SQLite, read directly — no `antigravity sync` needed) | +| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo) (international) | Cached via `tokscale trae sync` to `~/.config/tokscale/trae-cache/sessions/*.json` (account-level usage from the official API) | +| Warp | [Warp](https://www.warp.dev/) / Oz | Cached via `tokscale warp sync` to `~/.config/tokscale/warp-cache/usage.json` (aggregate requests and spend only; no token transcripts) | +| Grok Build | Grok Build | `$GROK_HOME/sessions/*/*/updates.jsonl` (fallback: `~/.grok/sessions/*/*/updates.jsonl`) | +| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db` (macOS: `~/Library/Application Support/Zed/threads/threads.db`; Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`; hosted Zed models only, not external ACP agents) | +| Kiro | Kiro | `~/.kiro/sessions/cli/*.json` (+ `*.jsonl`), `~/.local/share/kiro-cli/data.sqlite3` (macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`), and Kiro IDE globalStorage snapshots (`Kiro/User/globalStorage/kiro.kiroagent`; macOS Application Support, Linux `~/.config/Kiro`, Windows `%APPDATA%\Kiro`) | +| Cline | [Cline](https://github.com/cline/cline) | VS Code globalStorage tasks (Linux: `~/.config/Code/...`; macOS: `~/Library/Application Support/Code/...`; Windows: `%APPDATA%\Code\...`; server: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/`) | +| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/` (override via `GJC_CODING_AGENT_DIR`, `GJC_CONFIG_DIR`, `PI_CONFIG_DIR`; `$XDG_DATA_HOME/gjc/sessions/` on Linux/macOS) | +| Jcode | [Jcode](https://github.com/1jehuang/jcode) | `~/.jcode/sessions/session_*.json` + `session_*.journal.jsonl` sidecars (override via `JCODE_HOME`) | +| MiMo Code | [MiMo Code](https://github.com/XiaomiMiMo/MiMo-Code) | `~/.local/share/mimocode/mimocode.db` (XDG data dir; SQLite) | +| Junie | [Junie](https://www.jetbrains.com/junie/) | `~/.junie/sessions/*/events.jsonl` | +| Command Code | [Command Code](https://github.com/CommandCodeAI/command-code) | `~/.commandcode/projects/**/*.jsonl` (token usage estimated from transcripts at ~4 chars/token; not persisted on disk) | +| ZCode | [ZCode](https://zcode.z.ai/) | `~/.zcode/cli/db/db.sqlite` (v2 usage database) and `~/.zcode/projects/**/*.jsonl` (legacy transcripts) | +| OpenCodeReview | [OpenCodeReview](https://github.com/alibaba/open-code-review) | `~/.opencodereview/sessions/**/*.jsonl` | +| CodeBuddy | [CodeBuddy](https://www.codebuddy.cn/docs/cli/overview) (CLI, IDE, VS Code plugin) | `~/.codebuddy/projects/**/*.jsonl` + extension logs | +| WorkBuddy | WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` + SQLite fallback | +| Devin CLI | [Devin CLI](https://devin.ai/) | `~/.local/share/devin/cli/sessions.db` (SQLite) | +| Devin Desktop | [Devin Desktop](https://devin.ai/) | ACP events: macOS `~/Library/Application Support/Devin/User/acp-events/`; Linux `~/.config/Devin/User/acp-events/`; Windows `%APPDATA%\Devin\User\acp-events\` | +| Synthetic | [Synthetic](https://synthetic.new/) | Re-attributed from other sources via `hf:` model prefix or `synthetic` provider (+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | Get real-time pricing calculations using [🚅 LiteLLM's pricing data](https://github.com/BerriAI/litellm), with support for tiered pricing models and cache token discounts. @@ -111,10 +124,12 @@ In the age of AI-assisted development, **tokens are the new energy**. They power - [Date Filtering](#date-filtering) - [Pricing Lookup](#pricing-lookup) - [Social](#social) + - [Autosubmit](#autosubmit) - [Cursor IDE Commands](#cursor-ide-commands) - [Antigravity Commands](#antigravity-commands) - [Trae Commands](#trae-commands) - [Warp/Oz Commands](#warpoz-commands) + - [Task-Attributed Report](#task-attributed-report) - [Subscription Usage](#subscription-usage) - [Example Output](#example-output---light-version) - [Configuration](#configuration) @@ -148,15 +163,16 @@ In the age of AI-assisted development, **tokens are the new energy**. They power - **Interactive TUI Mode** - Beautiful terminal UI powered by Ratatui (default mode) - 6 interactive views: Overview, Models, Daily, Hourly, Stats, Agents (plus an optional Minutely view, opt-in via `minutelyTabEnabled`) - Keyboard & mouse navigation - - GitHub-style contribution graph with 9 color themes + - GitHub-style contribution graph with configurable color themes - Real-time filtering and sorting - Zero flicker rendering -- **Multi-platform support** - Track usage across OpenCode, Claude Code, Codex CLI, Copilot CLI, Cursor IDE, Gemini CLI, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi CLI, Qwen CLI, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Zed, Kiro, Trae, Cline, Gajae-Code, and Synthetic +- **Multi-platform support** - Track usage across OpenCode, Claude Code, Codex CLI, Copilot CLI, Cursor IDE, Gemini CLI, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi CLI, Qwen CLI, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Antigravity CLI, Zed, Kiro, Trae, Warp/Oz, Cline, Gajae-Code, Grok Build, Jcode, MiMo Code, Command Code, Junie, ZCode, OpenCodeReview, CodeBuddy, WorkBuddy, Devin CLI, Devin Desktop, and Synthetic - **Real-time pricing** - Fetches current pricing from LiteLLM with 1-hour disk cache; automatic OpenRouter fallback and Cursor model pricing for newly released models - **Detailed breakdowns** - Input, output, cache read/write, and reasoning token tracking - **Native Rust core** - All parsing and aggregation done in Rust for 10x faster processing - **Web visualization** - Interactive contribution graph with 2D and 3D views - **Flexible filtering** - Filter by platform, date range, or year +- **Task-attributed reports** - LLM-powered session summarization and task grouping with multi-backend support (Apple FM, Claude, Codex, Gemini, Kiro) - **Export to JSON** - Generate data for external visualization tools - **Social Platform** - Share your usage, compete on leaderboards, and view public profiles @@ -267,12 +283,12 @@ The interactive TUI mode provides: - `h`: Toggle Daily/Hourly chart granularity (Overview tab) - `v`: Toggle Table/Profile view (Hourly tab) - `y`: Copy selected row to clipboard - - `p`: Cycle through 9 color themes + - `p`: Cycle through color themes - `r`: Refresh data; `Shift+R` toggles auto-refresh; `+`/`-` adjusts interval - `e`: Export to JSON - `q` or `Ctrl+C`: Quit - **Mouse Support**: Click tabs, buttons, and filters -- **Themes**: Green, Halloween, Teal, Blue, Pink, Purple, Orange, Monochrome, YlGnBu +- **Themes**: Green, Halloween, Teal, Blue, Pink, Purple, Orange, Monochrome, YlGnBu, Graphite, Lagoon, Dusk - **Settings Persistence**: Preferences saved to `~/.config/tokscale/settings.json` (see [Configuration](#configuration)) ### Group-By Strategies @@ -361,9 +377,9 @@ tokscale --client synthetic tokscale --client opencode,claude --week --json ``` -Possible values: `opencode`, `claude`, `codex`, `copilot`, `gemini`, `cursor`, `amp`, `codebuff`, `droid`, `openclaw`, `hermes`, `pi`, `kimi`, `qwen`, `roocode`, `kilocode`, `kilo`, `mux`, `crush`, `goose`, `antigravity`, `zed`, `kiro`, `trae`, `cline`, `gjc`, `synthetic`. +Possible values: `opencode`, `claude`, `codex`, `copilot`, `gemini`, `cursor`, `amp`, `codebuff`, `droid`, `openclaw`, `hermes`, `pi`, `kimi`, `qwen`, `roocode`, `kilocode`, `kilo`, `mux`, `crush`, `goose`, `antigravity`, `antigravity-cli`, `zed`, `kiro`, `trae`, `warp`, `cline`, `gjc`, `grok`, `jcode`, `micode`, `commandcode`, `junie`, `zcode`, `opencodereview`, `codebuddy`, `synthetic`. -> **Deprecation notice**: The legacy single-client flags (`--opencode`, `--claude`, `--codex`, etc.) still work for backward compatibility but are hidden from `--help` and will be removed in the next major release. Migrate to `--client` whenever possible. Running tokscale in an interactive terminal will print a one-line warning when a legacy flag is used. +> **Breaking change (v4.0.0):** The per-client boolean flags (`--opencode`, `--claude`, `--codex`, etc.) have been removed and now error. Use the canonical `--client`/`-c` flag instead — e.g. `tokscale --client opencode,claude`. ### Date Filtering @@ -372,6 +388,7 @@ Date filters work across all commands that generate reports (`tokscale`, `toksca ```bash # Quick date shortcuts tokscale --today # Today only +tokscale --yesterday # Yesterday only tokscale --week # Last 7 days tokscale --month # Current calendar month @@ -426,7 +443,7 @@ Create `custom-pricing.json` in Tokscale's config directory (`~/.config/tokscale ```json { - "$schema": "https://tokscale.dev/custom-pricing.schema.json", + "$schema": "https://tokscale.ai/custom-pricing.schema.json", "models": { "accounts/fireworks/routers/kimi-k2p6-turbo": { "input_cost_per_million_tokens": 2.00, @@ -491,7 +508,7 @@ tokscale submit TOKSCALE_API_TOKEN=tt_xxx tokscale submit # Revoke a token: visit Settings > API Tokens on the leaderboard site -# (https://tokscale.com/settings) and click "Revoke" on the token row. +# (https://tokscale.ai/settings) and click "Revoke" on the token row. # Revocation takes effect immediately — subsequent requests with that # token will get HTTP 401 "Invalid API token". @@ -507,6 +524,31 @@ tokscale logout CLI Submit +### Autosubmit + +Autosubmit schedules the normal `tokscale submit` flow with the operating system scheduler. It is useful for keeping your public profile current without a manual terminal run. + +```bash +# Enable periodic submission. Uses launchd on macOS, systemd user timers on Linux +# when available, cron as a Linux fallback, and Windows Task Scheduler on Windows. +tokscale autosubmit enable --interval 24h + +# Keep the same client and date filters you would pass to submit. +tokscale autosubmit enable --interval 2h --client opencode,claude --week + +# Show saved settings and the last run/error. +tokscale autosubmit status +tokscale autosubmit status --json + +# Run once now, even if the saved interval has not elapsed. +tokscale autosubmit run --force + +# Disable autosubmit and remove the scheduler entry. +tokscale autosubmit disable +``` + +Scheduled runs are non-interactive: they never prompt for GitHub auth or star confirmation. Run `tokscale login --token tt_xxx` once, or set `TOKSCALE_API_TOKEN` in the scheduler environment. Tokscale records scheduler state in `settings.json`, writes logs under `~/.config/tokscale/autosubmit/`, and uses a lock file so overlapping scheduler ticks do not submit twice. + ### Cursor IDE Commands Cursor IDE support uses Cursor's web API export, cached by Tokscale at `~/.config/tokscale/cursor-cache/usage*.csv`. Tokscale does not parse local Cursor Agent CLI state under `~/.cursor`. @@ -637,6 +679,71 @@ tokscale warp logout --purge-cache **How it works**: `tokscale warp sync` calls Warp's authenticated GraphQL API for account and workspace aggregate counters. Tokscale preserves request counts as message counts and vendor-reported spend as cost, but it never converts requests into synthetic tokens. Warp is excluded from default `submit` data because the public leaderboard accepts token-attributed usage, not aggregate request counters. +### Task-Attributed Report + +The `report` command generates a task-attributed usage breakdown. It uses an LLM to summarize each session into a short title and category, then groups related sessions into high-level task clusters for a bird's-eye view of where your tokens went. + +```bash +# Basic report (today, default Apple FM summarizer) +tokscale report + +# Last 7 days +tokscale report --week + +# Use Claude Code as the summarizer backend +tokscale report --week --summarizer claude + +# Use Codex, Gemini, or Kiro +tokscale report --summarizer codex +tokscale report --summarizer gemini +tokscale report --summarizer kiro + +# Skip LLM summarization (show raw data only) +tokscale report --no-summarize + +# Re-summarize from scratch (resets cached summaries in range) +tokscale report --week --rebuild + +# Output as JSON +tokscale report --week --json + +# Filter by workspace or client +tokscale report --workspace my-project --client opencode +``` + +**Summarizer backends:** + +| Backend | Command | Notes | +|---------|---------|-------| +| `apple-fm` | (default) | On-device Apple Foundation Models via native Rust FFI (no Python). Enabled in the prebuilt Apple Silicon (macOS arm64) binary; runs on macOS 26+ with Apple Intelligence on, and transparently falls back to a built-in Rust heuristic everywhere else (Intel Macs, older macOS, Linux, Windows) — so the default works on every platform. | +| `claude` | `claude -p` | Requires Claude Code CLI installed and authenticated. | +| `codex` | `codex --quiet` | Requires Codex CLI installed and authenticated. | +| `gemini` | `gemini -p` | Requires Gemini CLI installed and authenticated. | +| `kiro` | `kiro --non-interactive` | Requires Kiro CLI installed and authenticated. | + +**How it works:** + +1. Sessions are scanned and inserted into a local SQLite wiki database (`wiki.db` in your platform config dir — e.g. `~/.config/tokscale/` on Linux, `~/Library/Application Support/tokscale/` on macOS) +2. Unsummarized sessions are sent to the chosen LLM backend in batches, which returns a title, category, description, and complexity for each +3. A second LLM pass groups all titled sessions into 3–8 high-level task clusters (e.g. "Kiro Auth", "Tokscale Report", "System Config") +4. Results are cached in the wiki DB — subsequent runs skip already-summarized sessions + +**Example output:** + +``` + Task Group Sess Tokens Cost + ─────────────────────────────────────────────────────────────────────── + Tokscale Development 19 4.2B $22.66 + Add task-attributed report command + Implement wiki DB schema + Fix pricing lookup for new models + System Config 28 2.1B $10.06 + Configure OpenCode workspace settings + Update shell aliases + Kiro Auth 4 890.5M $3.10 + Implement JWT refresh flow +``` + ### Subscription Usage Tokscale can fetch and display your real-time subscription quota across AI providers. This shows how much of your plan you've used and when limits reset. @@ -652,7 +759,7 @@ tokscale usage --json tokscale usage --light ``` -In the TUI, navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh. +In the TUI, navigate to the **Usage** tab to see subscription data. Use `[Refresh]` to refresh subscription quotas. The keyboard refresh shortcut `r` uses the same refresh path. > **Note**: Subscription quotas and balances are **vendor-reported** — tokscale calls each provider's own quota endpoint and surfaces the response verbatim. Numbers reflect what the provider reports (which is also what shows up in their official dashboards) and are not independently verified against tokscale's own usage tracking. @@ -661,15 +768,54 @@ In the TUI, navigate to the **Usage** tab to see subscription data. Press `u` or | Provider | Auth Method | Metrics | Setup | |----------|-------------|---------|-------| | **Claude** | OAuth (credentials file or macOS Keychain) | Session (5hr), Weekly, Opus quotas | Run `claude` to log in | -| **Codex** (OpenAI) | OAuth (`~/.config/codex/auth.json` or `~/.codex/auth.json`) | Session, Weekly quotas | Run `codex` to log in | +| **Codex** (OpenAI) | OAuth (`~/.config/codex/auth.json`, `~/.codex/auth.json`, or saved Tokscale accounts) | Session, Weekly quotas | Use `[Add Codex]` in the TUI Usage tab, run `codex` to log in, or import an existing auth with `tokscale codex import --name work` | | **Z.ai** | API key (env var) | Token limits, Web Searches | Set `ZAI_API_KEY` or `GLM_API_KEY` | | **Amp** | API key (`~/.local/share/amp/secrets.json`) | Free tier balance, Credits | Run `amp` to log in | | **GitHub Copilot** | GitHub token (keychain or `~/.config/gh/hosts.yml`) | Premium interactions, Chat quotas | Run `gh auth login` | +| **Grok Build** | OAuth (`~/.grok/auth.json`) | Credits, subscription plan | Run `grok login` | | **Kimi** | OAuth (`~/.kimi/credentials/kimi-code.json`) | Session, Weekly quotas | Run `kimi` to log in | | **MiniMax** | API key (env var) | Prompt quotas per model | Set `MINIMAX_API_KEY` or `MINIMAX_API_TOKEN` | +| **MiniMax Token Plan** | API key (env var) | Interval + weekly remaining-percent quotas (per region: CN minimaxi.com + Global minimax.io) | Set `MINIMAX_TOKEN_PLAN_CN_KEY` and/or `MINIMAX_TOKEN_PLAN_GLOBAL_KEY` | +| **Sakana** (Fugu) | Session cookie (env var or file) — billing-console HTML scrape, no public API | 5-hour, Weekly quota windows (plan tier + monthly price as metadata) | Set `SAKANA_SESSION_COOKIE` (see [docs/providers/sakana.md](docs/providers/sakana.md)) | Providers are auto-detected — only those with valid credentials are shown. If a provider is missing, ensure you've logged in or set the required environment variable. +#### Codex Multi-Account Usage + +Tokscale can save multiple Codex OAuth accounts for subscription usage display. The TUI Usage tab groups saved accounts under one **Codex** section. The active account is marked with `*`; inactive accounts can be selected with `[Use]`; account removal uses `[Remove]` followed by `[Confirm]`. + +To add an account without leaving the TUI, click `[Add Codex]` in the Usage tab. Tokscale starts `codex login` with a temporary `CODEX_HOME`, displays the login output in the Usage tab, imports the resulting auth into Tokscale's saved account store, and then refreshes usage. This keeps the login isolated and does not switch the current Codex auth; click `[Use]` on a saved account when you want Tokscale to write that account into the real Codex auth file. + +The CLI commands are still available for scripted or manual account management, plus a separate opt-in account-activity snapshot: + +```bash +# Save the current Codex auth as a named Tokscale account +tokscale codex import --name work + +# List saved Codex accounts +tokscale codex accounts +tokscale codex accounts --json + +# Switch the active Codex account and write Codex auth.json +tokscale codex switch work + +# Stop tracking a saved Codex account (removes it from Tokscale's store +# only — the codex CLI's own auth.json/login is never touched) +tokscale codex remove personal + +# Check subscription usage for the active or a named account +tokscale codex status +tokscale codex status --name personal --json + +# Fetch the active Codex app-server account activity separately from local totals +tokscale codex activity +tokscale codex activity --json +``` + +When saved Codex accounts exist, `tokscale usage --json` includes structured account metadata for each Codex entry and the TUI displays those entries under one Codex group. Without saved accounts, Tokscale falls back to the current Codex auth discovery path (`CODEX_HOME/auth.json`, `~/.config/codex/auth.json`, `~/.codex/auth.json`, then macOS Keychain). + +`tokscale codex activity` uses only the installed Codex app-server's active authentication to fetch a timestamped, account-level snapshot. It is supplemental data: it is never included in local totals, reports, exports, submissions, or leaderboards. + #### Example Output ``` @@ -716,7 +862,7 @@ Tokscale stores settings in `~/.config/tokscale/settings.json`: | Setting | Type | Default | Description | |---------|------|---------|-------------| -| `colorPalette` | string | `"blue"` | TUI color theme (green, halloween, teal, blue, pink, purple, orange, monochrome, ylgnbu) | +| `colorPalette` | string | `"blue"` | TUI color theme (green, halloween, teal, blue, pink, purple, orange, monochrome, ylgnbu, graphite, lagoon, dusk) | | `includeUnusedModels` | boolean | `false` | Show models with zero tokens in reports | | `autoRefreshEnabled` | boolean | `false` | Enable auto-refresh in TUI | | `autoRefreshMs` | number | `60000` | Auto-refresh interval (30000-3600000ms) | @@ -724,9 +870,10 @@ Tokscale stores settings in `~/.config/tokscale/settings.json`: | `defaultClients` | string[] | `[]` | Client filter applied when no `--client/-c` flag is passed. Accepts the same ids as `--client` (e.g. `["opencode", "claude", "synthetic"]`). Unknown ids are silently dropped. CLI flags always override this list completely — no merging. | | `light.writeCache` | boolean | `false` | When true, `tokscale --light` overwrites the TUI cache atomically after rendering. CLI flags `--write-cache` / `--no-write-cache` override per-invocation. | | `minutelyTabEnabled` | boolean | `false` | Show the per-minute Minutely tab in the TUI and aggregate per-minute usage during data loading. Default-off because minute-granularity is a niche/diagnostic view for most users and the per-minute bucketing has a non-trivial cost on large datasets. | +| `autosubmit` | object | disabled | Saved `tokscale autosubmit` state: interval, client/date filters, scheduler backend, last run time, and last error. Prefer `tokscale autosubmit enable/status/disable` over editing this object by hand. | | `scanner.extraScanPaths` | object | `{}` | Additional per-client scan roots for sessions outside Tokscale's default home-root locations | -Use `scanner.extraScanPaths` for persistent extra roots such as project-level `.codex` directories, imported Gemini/OpenClaw histories, or Hermes profile databases. Hermes entries may point at a profile directory containing `state.db` or directly at a `state.db` file. Tokscale merges these paths with the default scan roots on every run and deduplicates overlapping roots by canonical path. +Use `scanner.extraScanPaths` for persistent extra roots such as project-level `.codex` directories or imported Gemini/OpenClaw histories. Tokscale automatically discovers Hermes profile databases under `$HERMES_HOME/profiles/*/state.db` (or `~/.hermes/profiles/*/state.db` when `HERMES_HOME` is unset). Use `scanner.extraScanPaths.hermes` only for non-standard Hermes profile locations; entries may point at a profile directory containing `state.db` or directly at a `state.db` file. Tokscale merges these paths with the default scan roots on every run and deduplicates overlapping roots by canonical path. Use `defaultClients` to pin a personal default — for example, set it to `["opencode", "claude"]` if those are the only clients you use, and `tokscale` (with no flags) will scope every report to them automatically. Pass `--client` on the command line to override for a single run. @@ -749,7 +896,7 @@ After restart, the Minutely tab appears between Hourly and Stats in the tab stri The regenerable CLI/TUI/pricing/Wrapped caches now live under `~/.config/tokscale/cache/` (or `${TOKSCALE_CONFIG_DIR}/cache/` when overridden). Integration sync artifacts remain in client-specific cache roots such as `~/.config/tokscale/antigravity-cache/` and `~/.config/tokscale/trae-cache/`: - `tui-data-cache.json` — TUI startup cache -- `source-message-cache.bin` + `source-message-cache.lock` — source-message cache + lock file +- `source-message-cache-v2/` + `source-message-cache.lock` — sharded source-message cache + lock file - `pricing-litellm.json` / `pricing-openrouter.json` — pricing caches - `opencode-migration.json` — OpenCode migration record - `fonts/` and `images/` — Wrapped asset caches @@ -766,6 +913,7 @@ Environment variables override config file values. For CI/CD or one-off use: | `TOKSCALE_API_TOKEN` | unset | Tokscale personal API token for non-interactive `submit` and `delete-submitted-data` runs. Create one from Settings > API Tokens or save it locally with `tokscale login --token tt_xxx`. | | `TOKSCALE_EXTRA_DIRS` | unset | One-off extra session roots as `client:/abs/path,client:/abs/path` | | `TOKSCALE_CONFIG_DIR` | unset | Overrides the config directory root (where `settings.json`, `star-cache.json`, `cache/`, `antigravity-cache/`, and `trae-cache/` live). Absolute path recommended; relative paths resolve against the process CWD. Useful for CI sandboxes or pinning a non-default location. When set, tokscale will not fall back to the legacy macOS `~/Library/Application Support/tokscale/` path. | +| `TOKSCALE_FM_DEBUG` | unset | When set, prints Apple Foundation Models diagnostics (macOS version gate, dlopen dylib path, load/symbol errors) to stderr to explain why on-device apple-fm did or didn't engage. | ```bash # Example: Increase timeout for very large datasets @@ -854,7 +1002,7 @@ The frontend provides a GitHub-style contribution graph visualization: - **Interactive tooltips**: Hover for detailed daily breakdowns - **Day breakdown panel**: Click to see per-source and per-model details - **Year filtering**: Navigate between years -- **Source filtering**: Filter by platform (OpenCode, Claude, Codex, Copilot, Cursor, Gemini, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi, Qwen, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Zed, Kiro, Trae, Cline, Gajae-Code, Synthetic) +- **Source filtering**: Filter by platform (OpenCode, Claude, Codex, Copilot, Cursor, Gemini, Amp, Codebuff, Droid, OpenClaw, Hermes Agent, Pi, Kimi, Qwen, Roo Code, Kilo, Mux, Kilo CLI, Crush, Goose, Antigravity, Antigravity CLI, Zed, Kiro, Trae, Warp, Cline, Gajae-Code, Grok Build, Jcode, MiMo Code, Command Code, Junie, ZCode, OpenCodeReview, CodeBuddy, WorkBuddy, Devin CLI, Devin Desktop, Synthetic) - **Stats panel**: Total cost, tokens, active days, streaks - **FOUC prevention**: Theme applied before React hydrates (no flash) @@ -901,7 +1049,8 @@ customize the design. | `tokens`, `cost` | `compact` · `full` | Number format, set independently — `20.9B` vs `20,941,000,000` | | `rank` | `plain` (default, `#134`) · `percent` (`top 12%`) · `total` (`#134 / 1,174`) | How the leaderboard rank is shown | | `graph` | `1` to append the contribution graph (off by default) | Supported by `classic`, `minimal`, `terminal`, `orbit`, `blueprint`, `receipt` | -| `compact` | `1` for the compact layout | `classic` only | +| `view` | `2d` (default) · `3d` | Switch between the selected 2D card and the isometric contribution view | +| `compact` | `1` | Uses the compact Classic layout or compact number formatting in the 3D view | Examples: @@ -910,6 +1059,7 @@ Examples: ![](https://tokscale.ai/api/embed//svg?template=orbit&color=pink&rank=percent) ![](https://tokscale.ai/api/embed//svg?template=terminal&color=green&theme=light) ![](https://tokscale.ai/api/embed//svg?template=receipt&color=YlGnBu&graph=1) +![](https://tokscale.ai/api/embed//svg?view=3d&compact=1) ``` ### GitHub Profile Badge @@ -1129,16 +1279,18 @@ cd packages/core && bun run bench ### Native Module Targets -| Platform | Architecture | Status | -|----------|--------------|--------| -| macOS | x86_64 | ✅ Supported | -| macOS | aarch64 (Apple Silicon) | ✅ Supported | -| Linux | x86_64 (glibc) | ✅ Supported | -| Linux | aarch64 (glibc) | ✅ Supported | -| Linux | x86_64 (musl) | ✅ Supported | -| Linux | aarch64 (musl) | ✅ Supported | -| Windows | x86_64 | ✅ Supported | -| Windows | aarch64 | ✅ Supported | +| Platform | Architecture | +|----------|--------------| +| macOS | x86_64 | +| macOS | aarch64 (Apple Silicon) | +| Linux | x86_64 (glibc) | +| Linux | aarch64 (glibc) | +| Linux | x86_64 (musl) | +| Linux | aarch64 (musl) | +| Windows | x86_64 | +| Windows | aarch64 | + +On Linux, the launcher detects glibc vs musl automatically (via `process.report`, the musl dynamic loader at `/lib/ld-musl-*.so.1`, and `ldd`). If detection ever picks the wrong flavor — e.g. in minimal containers — set `TOKSCALE_LIBC=musl` (or `TOKSCALE_LIBC=gnu`) to force it. ### Windows Support @@ -1171,6 +1323,7 @@ AI coding tools store their session data in cross-platform locations. Most tools | Droid | `~/.factory/` | `%USERPROFILE%\.factory\` | Same path on all platforms | | Pi | `~/.pi/` and `~/.omp/` | `%USERPROFILE%\.pi\` and `%USERPROFILE%\.omp\` | Same path on all platforms (supports both Pi and [Oh My Pi](https://github.com/can1357/oh-my-pi)) | | Kimi CLI | `~/.kimi/` | `%USERPROFILE%\.kimi\` | Same path on all platforms | +| Kimi Code | `~/.kimi-code/` | `%USERPROFILE%\.kimi-code\` | Same path on all platforms | | Qwen CLI | `~/.qwen/` | `%USERPROFILE%\.qwen\` | Same path on all platforms | | Roo Code | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\rooveterinaryinc.roo-cline\tasks\` | VS Code globalStorage task logs | | Kilo | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\kilocode.kilo-code\tasks\` | VS Code globalStorage task logs | @@ -1185,7 +1338,17 @@ AI coding tools store their session data in cross-platform locations. Most tools | Kiro | `~/.kiro/sessions/cli/` and `~/.local/share/kiro-cli/data.sqlite3` | `%USERPROFILE%\.kiro\sessions\cli\` and `%USERPROFILE%\.local\share\kiro-cli\data.sqlite3` | Parses Kiro session files plus the Kiro CLI SQLite database when present | | Trae | `~/.config/tokscale/trae-cache/sessions/` | `%APPDATA%\tokscale\trae-cache\sessions\` | Synced once via `tokscale trae sync`; credentials are auto-discovered from any installed Trae IDE or Trae Solo desktop app | | Warp/Oz | `~/.config/tokscale/warp-cache/usage.json` | `%APPDATA%\tokscale\warp-cache\usage.json` | Synced via `tokscale warp sync`; aggregate requests and spend only, no token transcripts | +| Grok Build | `~/.grok/sessions/` | `%USERPROFILE%\.grok\sessions\` | Configurable via `GROK_HOME` env var; parses `updates.jsonl` session updates | +| Jcode | `~/.jcode/sessions/` | `%USERPROFILE%\.jcode\sessions\` | Configurable via `JCODE_HOME` env var; parses `session_*.json` snapshots plus `session_*.journal.jsonl` sidecars | +| MiMo Code | `~/.local/share/mimocode/` | `%USERPROFILE%\.local\share\mimocode\` | Uses XDG data directory; SQLite database `mimocode.db` | | Gajae-Code | `~/.gjc/agent/sessions/` | `%USERPROFILE%\.gjc\agent\sessions\` | Configurable via `GJC_CODING_AGENT_DIR` (also `GJC_CONFIG_DIR`/`PI_CONFIG_DIR`; `$XDG_DATA_HOME/gjc/sessions/` flattens on Linux/macOS) | +| Junie | `~/.junie/sessions/` | `%USERPROFILE%\.junie\sessions\` | Same home-relative path on all platforms; parses `events.jsonl` usage events | +| ZCode | `~/.zcode/cli/db/db.sqlite` and `~/.zcode/projects/` | `%USERPROFILE%\.zcode\cli\db\db.sqlite` and `%USERPROFILE%\.zcode\projects\` | Parses v2 SQLite model usage plus legacy `*.jsonl` session transcripts; Z.ai's ADE for GLM models | +| OpenCodeReview | `~/.opencodereview/sessions/` | `%USERPROFILE%\.opencodereview\sessions\` | Parses `*.jsonl` session transcripts; Alibaba's AI code review tool | +| CodeBuddy | `~/.codebuddy/projects/` + extension logs | `%USERPROFILE%\.codebuddy\projects\` + CodeBuddy / VS Code extension logs | Parses CodeBuddy CLI, IDE, and VS Code plugin token usage | +| WorkBuddy | `~/.workbuddy/projects/` + `~/.workbuddy/workbuddy.db` | `%USERPROFILE%\.workbuddy\projects\` + `%USERPROFILE%\.workbuddy\workbuddy.db` | Parses WorkBuddy token usage, with the aggregate SQLite database as a fallback | +| Devin CLI | `~/.local/share/devin/cli/sessions.db` | `%USERPROFILE%\.local\share\devin\cli\sessions.db` | Reads the authoritative local SQLite usage database | +| Devin Desktop | Linux: `~/.config/Devin/User/acp-events/`; macOS: `~/Library/Application Support/Devin/User/acp-events/` | `%APPDATA%\Devin\User\acp-events\` | Parses ACP usage events; the CLI database resolves matching session titles when present | | Synthetic | Re-attributed from other sources | Re-attributed from other sources | Detects `hf:` model prefix + `synthetic` provider | > **Note**: On Windows, `~` expands to `%USERPROFILE%` (e.g., `C:\Users\YourName`). These tools intentionally use Unix-style paths (like `.local/share`) even on Windows for cross-platform consistency, rather than Windows-native paths like `%APPDATA%`. @@ -1427,6 +1590,18 @@ Location: `~/.config/tokscale/warp-cache/usage.json` (synced via authenticated G Warp/Oz data is not fetched automatically by the root command. Run `tokscale warp login`, then `tokscale warp sync` before reports. Tokscale records only aggregate request counts and spend because Warp does not expose token-attributed local transcripts. +### Grok Build + +Location: `$GROK_HOME/sessions/*/*/updates.jsonl` (fallback: `~/.grok/sessions/*/*/updates.jsonl`) + +Grok Build data is parsed directly from local session updates. Current logs expose cumulative `totalTokens` counters without a stable input/output split, so Tokscale records positive per-turn deltas as input tokens. `grok-composer-2.5-fast` is temporarily mapped to the Composer 2.5 Fast pricing override until a dedicated public price is available. + +### Jcode + +Location: `$JCODE_HOME/sessions/session_*.json` (fallback: `~/.jcode/sessions/session_*.json`) plus matching `session_*.journal.jsonl` sidecars. + +Jcode data is parsed directly from local session snapshots. Tokscale reads assistant `messages[].token_usage` fields (`input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`, and `reasoning_output_tokens`) without spoofing another client identity. Matching journal sidecars are merged into the same session stream before deduplication so recent appended messages are included until Jcode checkpoints them into the snapshot. Stable message IDs are used for replay dedupe; malformed/custom records without IDs use a scoped fallback key. + ### OpenClaw Location: `~/.openclaw/agents/*/sessions/sessions.json` (also scans legacy paths: `~/.clawdbot/`, `~/.moltbot/`, `~/.moldbot/`) @@ -1449,7 +1624,7 @@ Session JSONL format with model_change events and assistant messages: ### Hermes Agent -Location: `$HERMES_HOME/state.db` (fallback: `~/.hermes/state.db`) +Location: `$HERMES_HOME/state.db` (fallback: `~/.hermes/state.db`) plus standard profile databases at `$HERMES_HOME/profiles/*/state.db` (or sibling `~/.hermes/profiles/*/state.db` when `HERMES_HOME` points at an active profile) Hermes stores session-level usage in a SQLite `sessions` table. Tokscale imports rows where `model` is present and token or cost totals are non-zero, uses `started_at` as the timestamp, preserves `message_count`, and prefers `actual_cost_usd` over `estimated_cost_usd`. @@ -1473,6 +1648,13 @@ wire.jsonl format with StatusUpdate messages: {"timestamp": 1770983426.420942, "message": {"type": "StatusUpdate", "payload": {"token_usage": {"input_other": 1562, "output": 2463, "input_cache_read": 0, "input_cache_creation": 0}, "message_id": "chatcmpl-xxx"}}} ``` +### Kimi Code + +Location: `~/.kimi-code/sessions/{WORKDIR}/{SESSION_UUID}/agents/{AGENT}/wire.jsonl` +```json +{"type":"usage.record","model":"kimi-code/kimi-for-coding","usage":{"inputOther":1163,"output":352,"inputCacheRead":22272,"inputCacheCreation":0},"usageScope":"turn","time":1780410897480} +``` + ### Qwen CLI Location: `~/.qwen/projects/{PROJECT_PATH}/chats/{CHAT_ID}.jsonl` @@ -1582,6 +1764,41 @@ Synthetic usage is detected via post-processing of existing agent session files. Tokscale also checks Octofriend SQLite at `~/.local/share/octofriend/sqlite.db` and parses token-bearing records when available. +### MiMo Code + +Location: `~/.local/share/mimocode/mimocode.db` (XDG data directory) + +MiMo Code stores session data in a SQLite database. Tokscale queries the `message` table joined with `session` for workspace context: + +```sql +SELECT m.id, m.session_id, m.data, NULLIF(s.directory, '') AS workspace_root +FROM message m +LEFT JOIN session s ON s.id = m.session_id +WHERE json_extract(m.data, '$.role') = 'assistant' + AND json_extract(m.data, '$.tokens') IS NOT NULL +``` + +The `data` column is a JSON blob with the following token-relevant fields: +```json +{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "providerID": "anthropic", + "cost": 0.0032, + "tokens": { + "input": 1200, + "output": 450, + "reasoning": 0, + "cache": { "read": 800, "write": 0 } + }, + "time": { "created": 1780410897000, "completed": 1780410912000 }, + "agent": "micode", + "path": { "root": "/Users/me/project" } +} +``` + +Tokscale deduplicates messages across forked sessions using a fingerprint of timestamps, model, provider, token counts, cost, and agent name. + ## Pricing Tokscale fetches real-time pricing from [LiteLLM's pricing database](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json). @@ -1590,6 +1807,8 @@ Tokscale fetches real-time pricing from [LiteLLM's pricing database](https://git **Cursor Model Pricing**: For very recently released models not yet in either LiteLLM or OpenRouter (e.g., `gpt-5.3-codex`), Tokscale includes hardcoded pricing sourced from [Cursor's model docs](https://cursor.com/en-US/docs/models). These overrides are checked after all upstream sources but before fuzzy matching, so they automatically yield once real upstream pricing becomes available. +**Sakana Fugu Pricing**: Fugu Ultra cost is estimated from Sakana's published pay-as-you-go rates; the `fugu` router model is intentionally left unpriced because its cost is the variable rate of whichever underlying model it orchestrated. + **Caching**: Pricing data is cached to disk with 1-hour TTL for fast startup: - LiteLLM cache: `~/.config/tokscale/cache/pricing-litellm.json` - OpenRouter cache: `~/.config/tokscale/cache/pricing-openrouter.json` (caches author pricing for models from supported providers) diff --git a/README.zh-cn.md b/README.zh-cn.md index c8d309af8..460f8ebe5 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -20,6 +20,7 @@
[![GitHub Release](https://img.shields.io/github/v/release/junhoyeo/tokscale?color=0073FF&labelColor=black&logo=github&style=flat-square)](https://github.com/junhoyeo/tokscale/releases) +[![npm Version](https://img.shields.io/npm/v/tokscale?color=0073FF&labelColor=black&style=flat-square&logo=npm)](https://www.npmjs.com/package/tokscale) [![npm Downloads](https://img.shields.io/npm/dt/tokscale?color=0073FF&labelColor=black&style=flat-square)](https://www.npmjs.com/package/tokscale) [![GitHub Contributors](https://img.shields.io/github/contributors/junhoyeo/tokscale?color=0073FF&labelColor=black&style=flat-square)](https://github.com/junhoyeo/tokscale/graphs/contributors) [![GitHub Forks](https://img.shields.io/github/forks/junhoyeo/tokscale?color=0073FF&labelColor=black&style=flat-square)](https://github.com/junhoyeo/tokscale/network/members) @@ -52,34 +53,49 @@ **Tokscale** 帮助您监控和分析以下平台的 Token 消耗: -| 图标 | 客户端 | 数据位置 | 支持状态 | -|------|----------|---------------|-----------| -| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+,包含 `opencode-stable.db` 等所有渠道) 或 `~/.local/share/opencode/storage/message/` | ✅ 支持 | -| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` | ✅ 支持 | -| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ 旧版: `.clawdbot`, `.moltbot`, `.moldbot`) | ✅ 支持 | -| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | ✅ 支持 | -| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | ✅ 支持 | -| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db`(回退:`~/.hermes/state.db`) | ✅ 支持 | -| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json`(回退:`~/.gemini/tmp/*/chats/*.json`) | ✅ 支持 | -| Cursor | [Cursor IDE](https://cursor.com/) | 通过 `~/.config/tokscale/cursor-cache/` API 同步 | ✅ 支持 | -| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | ✅ 支持 | -| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/`(+ `manicode-dev`、`manicode-staging`;可通过 `CODEBUFF_DATA_DIR` 覆盖) | ✅ 支持 | -| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | ✅ 支持 | -| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` and `~/.omp/agent/sessions/` ([Oh My Pi](https://github.com/can1357/oh-my-pi)) | ✅ 支持 | -| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | `~/.kimi/sessions/` | ✅ 支持 | -| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | ✅ 支持 | -| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | ✅ 支持 | -| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | ✅ 支持 | -| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | ✅ 支持 | -| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | ✅ 支持 | -| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json`(项目注册表;回退路径:`~/.local/share/crush/projects.json`) | ✅ 支持 | -| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db`(+ macOS Application Support、旧版 Block/goose 路径;可通过 `GOOSE_PATH_ROOT` 覆盖) | ✅ 支持 | -| Antigravity | [Google Antigravity](https://antigravity.google/) | 通过 `tokscale antigravity sync` 缓存到 `~/.config/tokscale/antigravity-cache/sessions/*.jsonl`(使用本地语言服务器 RPC) | ✅ 支持 | -| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo)(国际版) | 通过 `tokscale trae sync` 缓存到 `~/.config/tokscale/trae-cache/sessions/*.json`(来自官方 API 的账号级使用量) | ✅ 支持 | -| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db`(macOS: `~/Library/Application Support/Zed/threads/threads.db`;Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`;仅限托管 Zed 模型,不含外部 ACP 代理) | ✅ 支持 | -| Kiro | Kiro | `~/.kiro/sessions/cli/*.json`(+ `*.jsonl`)和 `~/.local/share/kiro-cli/data.sqlite3`(macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`) | ✅ 支持 | -| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/`(可通过 `GJC_CODING_AGENT_DIR`、`GJC_CONFIG_DIR`、`PI_CONFIG_DIR` 覆盖;Linux/macOS 上 `$XDG_DATA_HOME/gjc/sessions/` 亦支持) | ✅ 支持 | -| Synthetic | [Synthetic](https://synthetic.new/) | 通过 `hf:` 模型前缀或 `synthetic` provider 从其他来源重归属(+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | ✅ 支持 | +| 图标 | 客户端 | 数据位置 | +|------|----------|---------------| +| OpenCode | [OpenCode](https://github.com/sst/opencode) | `~/.local/share/opencode/opencode.db` (1.2+,包含 `opencode-stable.db` 等所有渠道) 或 `~/.local/share/opencode/storage/message/` | +| Claude | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/projects/` 和 `~/.claude/transcripts/` | +| OpenClaw | [OpenClaw](https://openclaw.ai/) | `~/.openclaw/agents/` (+ 旧版: `.clawdbot`, `.moltbot`, `.moldbot`) | +| Codex | [Codex CLI](https://github.com/openai/codex) | `~/.codex/sessions/` | +| Sakana Fugu | [Sakana Fugu](https://sakana.ai/fugu/) | 通过 Codex 追踪 — `~/.codex/sessions/*.jsonl` (`model_provider: sakana`) | +| Copilot | [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/use-the-github-copilot-coding-agent-in-cli) | `~/.copilot/otel/*.jsonl` (+ `COPILOT_OTEL_FILE_EXPORTER_PATH`) | +| Hermes Agent | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | `$HERMES_HOME/state.db` 和 `$HERMES_HOME/profiles/*/state.db`(回退:`~/.hermes/...`) | +| Gemini | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `$GEMINI_CLI_HOME/tmp/*/chats/*.json`(回退:`~/.gemini/tmp/*/chats/*.json`) | +| Cursor | [Cursor IDE](https://cursor.com/) | Cursor API 导出缓存于 `~/.config/tokscale/cursor-cache/usage*.csv`(而非 `~/.cursor`) | +| Amp | [Amp (AmpCode)](https://ampcode.com/) | `~/.local/share/amp/threads/` | +| Codebuff | [Codebuff](https://codebuff.com/) | `~/.config/manicode/`(+ `manicode-dev`、`manicode-staging`;可通过 `CODEBUFF_DATA_DIR` 覆盖) | +| Droid | [Droid (Factory Droid)](https://factory.ai/) | `~/.factory/sessions/` | +| Pi | [Pi](https://github.com/badlogic/pi-mono) | `~/.pi/agent/sessions/` 和 `~/.omp/agent/sessions/`([Oh My Pi](https://github.com/can1357/oh-my-pi)) | +| Kimi | [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) / [Kimi Code](https://github.com/MoonshotAI/kimi-code) | kimi-cli: `~/.kimi/sessions/` kimi-code: `~/.kimi-code/sessions/`(可通过 `KIMI_CODE_HOME` 覆盖) | +| Qwen | [Qwen CLI](https://github.com/QwenLM/qwen-cli) | `~/.qwen/projects/` | +| Roo Code | [Roo Code](https://github.com/RooCodeInc/Roo-Code) | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/`) | +| Kilo | [Kilo](https://github.com/Kilo-Org/kilocode) | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` (+ server: `~/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/`) | +| Kilo CLI | [Kilo CLI](https://github.com/nicepkg/kilo) | `~/.local/share/kilo/kilo.db` | +| Mux | [Mux](https://github.com/coder/mux) | `~/.mux/sessions/` | +| Crush | [Crush](https://crush.ai/) | `$XDG_DATA_HOME/crush/projects.json`(项目注册表;回退路径:`~/.local/share/crush/projects.json`) | +| Goose | [Goose](https://github.com/aaif-goose/goose) | `~/.local/share/goose/sessions/sessions.db`(+ macOS Application Support、旧版 Block/goose 路径;可通过 `GOOSE_PATH_ROOT` 覆盖) | +| Antigravity | [Google Antigravity](https://antigravity.google/) | 通过 `tokscale antigravity sync` 缓存到 `~/.config/tokscale/antigravity-cache/sessions/*.jsonl`(使用本地语言服务器 RPC) | +| Antigravity CLI | [Antigravity CLI](https://antigravity.google/) | `~/.gemini/antigravity-cli/conversations/*.db`(可通过 `GEMINI_CLI_HOME` 覆盖 Gemini 主目录;本地 SQLite,直接读取 — 无需 `antigravity sync`) | +| Trae | [Trae IDE](https://www.trae.ai/) / [Trae Solo](https://www.trae.ai/solo)(国际版) | 通过 `tokscale trae sync` 缓存到 `~/.config/tokscale/trae-cache/sessions/*.json`(来自官方 API 的账号级使用量) | +| Warp | [Warp](https://www.warp.dev/) / Oz | 通过 `tokscale warp sync` 缓存到 `~/.config/tokscale/warp-cache/usage.json`(仅汇总请求数和消费金额,不含 token 转录) | +| Grok Build | Grok Build | `$GROK_HOME/sessions/*/*/updates.jsonl`(回退:`~/.grok/sessions/*/*/updates.jsonl`) | +| Zed Agent | [Zed Agent](https://zed.dev/docs/ai/agent-panel) | `~/.local/share/zed/threads/threads.db`(macOS: `~/Library/Application Support/Zed/threads/threads.db`;Windows: `%LOCALAPPDATA%/Zed/threads/threads.db`;仅限托管 Zed 模型,不含外部 ACP 代理) | +| Kiro | Kiro | `~/.kiro/sessions/cli/*.json`(+ `*.jsonl`)、`~/.local/share/kiro-cli/data.sqlite3`(macOS: `~/Library/Application Support/kiro-cli/data.sqlite3`),以及 Kiro IDE globalStorage 快照(`Kiro/User/globalStorage/kiro.kiroagent`;macOS Application Support、Linux `~/.config/Kiro`、Windows `%APPDATA%\Kiro`) | +| Cline | [Cline](https://github.com/cline/cline) | VS Code globalStorage 任务(Linux: `~/.config/Code/...`;macOS: `~/Library/Application Support/Code/...`;Windows: `%APPDATA%\Code\...`;server: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/`) | +| Gajae-Code | [gajae-code (gjc)](https://github.com/Yeachan-Heo/gajae-code) | `~/.gjc/agent/sessions/`(可通过 `GJC_CODING_AGENT_DIR`、`GJC_CONFIG_DIR`、`PI_CONFIG_DIR` 覆盖;Linux/macOS 上 `$XDG_DATA_HOME/gjc/sessions/` 亦支持) | +| Jcode | [Jcode](https://github.com/1jehuang/jcode) | `~/.jcode/sessions/session_*.json` + `session_*.journal.jsonl` sidecar(可通过 `JCODE_HOME` 覆盖) | +| MiMo Code | [MiMo Code](https://github.com/XiaomiMiMo/MiMo-Code) | `~/.local/share/mimocode/mimocode.db`(XDG 数据目录;SQLite) | +| Junie | [Junie](https://www.jetbrains.com/junie/) | `~/.junie/sessions/*/events.jsonl` | +| Command Code | [Command Code](https://github.com/CommandCodeAI/command-code) | `~/.commandcode/projects/**/*.jsonl`(Token 使用量按 ~4 字符/Token 从转录估算;不会持久化到磁盘) | +| ZCode | [ZCode](https://zcode.z.ai/) | `~/.zcode/cli/db/db.sqlite`(v2 用量数据库)和 `~/.zcode/projects/**/*.jsonl`(旧版记录) | +| OpenCodeReview | [OpenCodeReview](https://github.com/alibaba/open-code-review) | `~/.opencodereview/sessions/**/*.jsonl` | +| CodeBuddy | [CodeBuddy](https://www.codebuddy.cn/docs/cli/overview)(CLI、IDE、VS Code 插件) | `~/.codebuddy/projects/**/*.jsonl` + 扩展日志 | +| WorkBuddy | WorkBuddy | `~/.workbuddy/projects/**/*.jsonl` + SQLite 回退 | +| Devin CLI | [Devin CLI](https://devin.ai/) | `~/.local/share/devin/cli/sessions.db`(SQLite) | +| Devin Desktop | [Devin Desktop](https://devin.ai/) | ACP 事件:macOS `~/Library/Application Support/Devin/User/acp-events/`;Linux `~/.config/Devin/User/acp-events/`;Windows `%APPDATA%\Devin\User\acp-events\` | +| Synthetic | [Synthetic](https://synthetic.new/) | 通过 `hf:` 模型前缀或 `synthetic` provider 从其他来源重归属(+ [Octofriend](https://github.com/synthetic-lab/octofriend): `~/.local/share/octofriend/sqlite.db`) | 使用 [🚅 LiteLLM 的价格数据](https://github.com/BerriAI/litellm)提供实时价格计算,支持分层定价模型和缓存 Token 折扣。 @@ -106,9 +122,13 @@ - [日期筛选](#日期筛选) - [价格查询](#价格查询) - [社交平台命令](#社交平台命令) + - [Autosubmit](#autosubmit) - [Cursor IDE 命令](#cursor-ide-命令) - [Antigravity 命令](#antigravity-命令) - [Trae 命令](#trae-命令) + - [Warp/Oz 命令](#warpoz-命令) + - [任务归因报告](#任务归因报告) + - [订阅使用量](#订阅使用量) - [示例输出](#示例输出--light-版本) - [配置](#配置) - [环境变量](#环境变量) @@ -143,15 +163,16 @@ - **交互式 TUI 模式** - 由 Ratatui 驱动的精美终端 UI(默认模式) - 6 个交互式视图:概览、模型、每日、每时、统计、代理(可选的 Minutely 视图通过 `minutelyTabEnabled` 启用) - 键盘和鼠标导航 - - 9 种颜色主题的 GitHub 风格贡献图 + - 支持可配置颜色主题的 GitHub 风格贡献图 - 实时筛选和排序 - 零闪烁渲染 -- **多平台支持** - 跟踪 OpenCode、Claude Code、Codex CLI、Copilot CLI、Cursor IDE、Gemini CLI、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi CLI、Qwen CLI、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Zed、Kiro、Trae、Gajae-Code 和 Synthetic 的使用情况 +- **多平台支持** - 跟踪 OpenCode、Claude Code、Codex CLI、Copilot CLI、Cursor IDE、Gemini CLI、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi CLI、Qwen CLI、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Antigravity CLI、Zed、Kiro、Trae、Warp/Oz、Cline、Gajae-Code、Grok Build、Jcode、MiMo Code、Command Code、Junie、ZCode、OpenCodeReview、CodeBuddy、WorkBuddy、Devin CLI、Devin Desktop 和 Synthetic 的使用情况 - **实时定价** - 从 LiteLLM 获取当前价格,带 1 小时磁盘缓存;OpenRouter 自动回退和新模型的 Cursor 定价支持 - **详细分解** - 输入、输出、缓存读写和推理 Token 跟踪 - **原生 Rust 核心** - 所有解析和聚合在 Rust 中完成,处理速度提升 10 倍 - **Web 可视化** - 带 2D 和 3D 视图的交互式贡献图 - **灵活筛选** - 按平台、日期范围或年份筛选 +- **任务归因报告** - 由 LLM 驱动的会话摘要与任务分组,支持多种后端(Apple FM、Claude、Codex、Gemini、Kiro) - **导出为 JSON** - 为外部可视化工具生成数据 - **社交平台** - 分享使用情况、排行榜竞争、查看公开个人资料 @@ -248,7 +269,7 @@ tokscale models --json > report.json # 保存到文件 交互式 TUI 模式提供: -- **6 个视图**:概览(图表 + 热门模型)、模型、每日、每时、统计(贡献图)、代理 +- **8 个视图**:概览(图表 + 热门模型)、Usage(订阅配额)、模型、每日、每时、统计(贡献图)、代理。按分钟视图(Minutely)默认隐藏,可在 `settings.json` 中通过 `minutelyTabEnabled` 启用 —— 参见[配置](#配置) - **键盘导航**: - `←/→/Tab/BackTab`:切换视图 - `↑/↓` 或 `Home/End`:导航列表 @@ -257,16 +278,16 @@ tokscale models --json > report.json # 保存到文件 - `c/d/t`:按成本/日期/Token 排序 - `j`:跳转到今天 - `s`:打开来源选择对话框 - - `g`:打开分组方式选择对话框(模型、客户端+模型、客户端+提供商+模型) + - `g`:打开分组方式选择对话框(模型、客户端+模型、客户端+提供商+模型、工作区+模型、会话+模型、客户端+会话+模型) - `h`:切换日/时图表粒度(Overview 标签) - `v`:切换表格/Profile 视图(Hourly 标签) - `y`:复制选中行到剪贴板 - - `p`:循环 9 种颜色主题 + - `p`:循环颜色主题 - `r`:刷新数据;`Shift+R` 切换自动刷新;`+`/`-` 调整间隔 - `e`:导出为 JSON - `q` 或 `Ctrl+C`:退出 - **鼠标支持**:点击标签、按钮和筛选器 -- **主题**:Green、Halloween、Teal、Blue、Pink、Purple、Orange、Monochrome、YlGnBu +- **主题**:Green、Halloween、Teal、Blue、Pink、Purple、Orange、Monochrome、YlGnBu、Graphite、Lagoon、Dusk - **设置持久化**:偏好设置保存到 `~/.config/tokscale/settings.json`(参见[配置](#配置)) ### 分组策略 @@ -278,6 +299,9 @@ tokscale models --json > report.json # 保存到文件 | **模型** | `--group-by model` | ✅ | 每个模型一行 — 合并所有客户端和提供商 | | **客户端 + 模型** | `--group-by client,model` | | 每个客户端-模型对一行 | | **客户端 + 提供商 + 模型** | `--group-by client,provider,model` | | 最详细 — 不合并 | +| **工作区 + 模型** | `--group-by workspace,model` | | 先按工作区键、再按模型对本地使用量分组 | +| **会话 + 模型** | `--group-by session,model` | | 每个 `session_id` 和模型一行 — 将成本归因到特定的 agent-CLI 会话 | +| **客户端 + 会话 + 模型** | `--group-by client,session,model` | | 每个客户端、会话和模型一行 — 适用于按 `session_id` 关联的多代理运行器 | **`--group-by model`**(最精简) @@ -301,6 +325,33 @@ tokscale models --json > report.json # 保存到文件 | OpenCode | anthropic | claude-opus-4-5 | $168 | | Claude | anthropic | claude-opus-4-5 | $970 | +**`--group-by session,model`**(按会话归因成本) + +`tokscale models --json --group-by session,model` 会为每个 `(session_id, model)` 输出一个条目。每个条目都包含顶层的 `sessionId` 字段,以便下游工具(例如多代理 IDE)能将成本数据关联回特定的 agent-CLI 会话: + +```json +{ + "groupBy": "session,model", + "entries": [ + { + "sessionId": "019e1e27-af49-7cd1-89b7-7bad1c3f3be2", + "client": "codex", + "provider": "openai", + "model": "gpt-5", + "input": 25251, + "output": 47, + "cacheRead": 1920, + "cacheWrite": 0, + "reasoning": 40, + "messageCount": 12, + "cost": 0.0123 + } + ] +} +``` + +当你还需要每行都带有客户端名称时,请使用 `--group-by client,session,model`(一次涵盖全部 20+ 个受支持的 CLI)。 + ### 按平台筛选 使用 `--client`(短选项 `-c`)将报告范围限定为一个或多个客户端。该选项可重复使用,支持逗号分隔的值,并适用于所有报告命令: @@ -325,9 +376,9 @@ tokscale --client synthetic tokscale --client opencode,claude --week --json ``` -可用值:`opencode`、`claude`、`codex`、`copilot`、`gemini`、`cursor`、`amp`、`codebuff`、`droid`、`openclaw`、`hermes`、`pi`、`kimi`、`qwen`、`roocode`、`kilocode`、`kilo`、`mux`、`crush`、`goose`、`antigravity`、`zed`、`kiro`、`trae`、`gjc`、`synthetic`。 +可用值:`opencode`、`claude`、`codex`、`copilot`、`gemini`、`cursor`、`amp`、`codebuff`、`droid`、`openclaw`、`hermes`、`pi`、`kimi`、`qwen`、`roocode`、`kilocode`、`kilo`、`mux`、`crush`、`goose`、`antigravity`、`antigravity-cli`、`zed`、`kiro`、`trae`、`warp`、`cline`、`gjc`、`grok`、`jcode`、`micode`、`commandcode`、`junie`、`zcode`、`synthetic`。 -> **弃用通知**:旧的单客户端选项(`--opencode`、`--claude`、`--codex` 等)出于向后兼容仍然可用,但已从 `--help` 中隐藏,将在下一个主要版本中移除。请尽量迁移到 `--client`。在交互式终端中使用旧选项时会输出一行警告。 +> **破坏性变更(v4.0.0)**:单客户端布尔选项(`--opencode`、`--claude`、`--codex` 等)已被移除,现在会直接报错。请改用规范的 `--client`/`-c` 选项——例如 `tokscale --client opencode,claude`。 ### 日期筛选 @@ -336,6 +387,7 @@ tokscale --client opencode,claude --week --json ```bash # 快速日期快捷方式 tokscale --today # 仅今天 +tokscale --yesterday # 仅昨天 tokscale --week # 最近 7 天 tokscale --month # 本月 @@ -366,19 +418,55 @@ tokscale pricing "grok-code" # 强制指定提供商来源 tokscale pricing "grok-code" --provider openrouter tokscale pricing "claude-3-5-sonnet" --provider litellm + +# 查看自定义价格覆盖 +tokscale pricing list-overrides ``` **查询策略:** 价格查询使用多步解析策略: -1. **精确匹配** - 在 LiteLLM/OpenRouter 数据库中直接查找 -2. **别名解析** - 解析友好名称(例如:`big-pickle` → `glm-4.7`) -3. **层级后缀剥离** - 移除质量层级(`gpt-5.2-xhigh` → `gpt-5.2`) -4. **版本标准化** - 处理版本格式(`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) -5. **提供商前缀匹配** - 尝试常见前缀(`anthropic/`、`openai/` 等) -6. **Cursor 模型定价** - LiteLLM/OpenRouter 中尚未收录的模型的硬编码定价(例如:`gpt-5.3-codex`) -7. **模糊匹配** - 部分模型名称的词边界匹配 +1. **自定义价格覆盖** - 来自 `~/.config/tokscale/custom-pricing.json` 的用户自定义精确条目 +2. **精确匹配** - 在 LiteLLM/OpenRouter 数据库中直接查找 +3. **别名解析** - 解析友好名称(例如:`big-pickle` → `glm-4.7`) +4. **层级后缀剥离** - 移除质量层级(`gpt-5.2-xhigh` → `gpt-5.2`) +5. **版本标准化** - 处理版本格式(`claude-3-5-sonnet` ↔ `claude-3.5-sonnet`) +6. **提供商前缀匹配** - 尝试常见前缀(`anthropic/`、`openai/` 等) +7. **Cursor 模型定价** - LiteLLM/OpenRouter 中尚未收录的模型的硬编码定价(例如:`gpt-5.3-codex`) +8. **模糊匹配** - 部分模型名称的词边界匹配 + +### 自定义价格覆盖 + +在 Tokscale 的配置目录中创建 `custom-pricing.json`(macOS/Linux 上默认为 `~/.config/tokscale/custom-pricing.json`;若设置了 `TOKSCALE_CONFIG_DIR`,则为其解析出的同一目录),以覆盖上游价格数据库尚未正确收录的模型 ID 的价格。 + +```json +{ + "$schema": "https://tokscale.ai/custom-pricing.schema.json", + "models": { + "accounts/fireworks/routers/kimi-k2p6-turbo": { + "input_cost_per_million_tokens": 2.00, + "output_cost_per_million_tokens": 8.00, + "cache_read_input_token_cost_per_million_tokens": 0.30, + "source": "https://docs.fireworks.ai/serverless/pricing", + "notes": "Fireworks Kimi K2.6 Turbo (preview)" + }, + "accounts/fireworks/models/kimi-k2p6": { + "input_cost_per_million_tokens": 0.95, + "output_cost_per_million_tokens": 4.00, + "cache_read_input_token_cost_per_million_tokens": 0.16 + }, + "kimi-k2p6-turbo": { + "input_cost_per_million_tokens": 2.00, + "output_cost_per_million_tokens": 8.00 + } + } +} +``` + +覆盖价格以每百万 Token 的美元数输入,这与大多数 API 提供商公布价格的方式一致;Tokscale 会在内部将其转换为每 Token 的费率。`input_cost_per_million_tokens` 或 `output_cost_per_million_tokens` 中至少要有一个存在且为正值,缓存读取/缓存创建字段为可选。为兼容复制粘贴,也接受 LiteLLM 风格的每 Token 字段名,例如 `input_cost_per_token`、`output_cost_per_token` 和 `cache_read_input_token_cost`,但推荐面向用户使用每百万的命名形式。要省略某个层级或缓存价格,直接不写该字段即可;负值或非有限值会被视为无效,并跳过整个模型条目,以免拼写错误悄悄改变统计。可选的 `source` 和 `notes` 字段会被 Tokscale 忽略,可用于您自己的记账。 + +覆盖是仅精确匹配且不区分大小写的。Tokscale 先检查原始模型 ID,再检查现有的合成 `/models/` 归一化,然后才在没有覆盖匹配时回退到 LiteLLM、OpenRouter、Cursor 定价和模糊匹配。原始精确匹配优先于归一化精确匹配,因此 `accounts/fireworks/routers/kimi-k2p6-turbo` 可以覆盖某个特定网关的模型,而 `kimi-k2p6-turbo` 可以覆盖归一化的 `/models/` 路径。覆盖在启动时仅加载一次;编辑文件后请重启命令。这是在等待上游 LiteLLM 价格更新期间,针对错误模型定价 Bug 的推荐本地修复方案。 **提供商优先级:** @@ -400,12 +488,28 @@ tokscale pricing "claude-3-5-sonnet" --provider litellm # 登录 Tokscale(打开浏览器进行 GitHub 认证) tokscale login +# 无需浏览器认证即可保存已有的 Tokscale API token +tokscale login --token tt_xxx + # 查看当前登录用户 tokscale whoami +# 将已保存的 API token 显示为二维码(便于分享到另一台设备) +# 编码内容为 {"token":"tt_xxx","username":"..."} —— 可用任意二维码扫描器扫描 +tokscale qr + # 提交使用量数据到排行榜 tokscale submit +# 在 CI/无头环境中提交,且不写入凭据 +# 优先级:TOKSCALE_API_TOKEN 环境变量 > 已保存的凭据文件(~/.config/tokscale/credentials.json)。 +# 设置了该环境变量时,本次调用会忽略已保存的文件。 +TOKSCALE_API_TOKEN=tt_xxx tokscale submit + +# 撤销 token:访问排行榜站点的 Settings > API Tokens +#(https://tokscale.ai/settings),点击对应 token 行的 "Revoke"。 +# 撤销立即生效 —— 之后使用该 token 的请求将收到 HTTP 401 "Invalid API token"。 + # 带筛选提交 tokscale submit --client opencode,claude --since 2024-01-01 @@ -418,6 +522,31 @@ tokscale logout CLI Submit +### Autosubmit + +Autosubmit 通过操作系统的调度器来安排常规的 `tokscale submit` 流程。它可以让你无需手动运行终端命令即可保持公开资料的最新状态。 + +```bash +# 启用定期提交。macOS 使用 launchd,Linux 在可用时使用 systemd 用户定时器 +# (回退到 cron),Windows 使用 Windows 任务计划程序。 +tokscale autosubmit enable --interval 24h + +# 可以沿用与 submit 相同的客户端/日期筛选参数。 +tokscale autosubmit enable --interval 2h --client opencode,claude --week + +# 显示已保存的设置以及最近一次运行/错误。 +tokscale autosubmit status +tokscale autosubmit status --json + +# 即使保存的间隔尚未到达,也立即运行一次。 +tokscale autosubmit run --force + +# 禁用 autosubmit 并移除调度器条目。 +tokscale autosubmit disable +``` + +计划任务的运行是非交互式的:它们不会提示 GitHub 认证或点星确认。请先运行一次 `tokscale login --token tt_xxx`,或在调度器环境中设置 `TOKSCALE_API_TOKEN`。Tokscale 会将调度器状态记录在 `settings.json` 中,将日志写入 `~/.config/tokscale/autosubmit/`,并使用锁文件确保调度器的多次触发不会重复提交。 + ### Cursor IDE 命令 Cursor IDE 需要通过会话令牌进行单独认证(与社交平台登录不同): @@ -519,6 +648,175 @@ tokscale trae logout --variant solo > **中国区版本**:中国区版本(`trae.com.cn`)目前有意不支持。CN 后端暂未暴露按会话查询使用量的官方 API;如果上游提供正式端点,再加入支持。 +### Warp/Oz 命令 + +Warp/Oz 不提供本地 token 转录。Tokscale 仅同步 Warp GraphQL API 返回的汇总请求数和消费计数器,并将其以 `warp` / `aggregate-requests` 行(token 字段均为零)的形式上报。 + +```bash +# 保存从已认证的 Warp 请求中复制的 bearer token 或 Cookie 头 +tokscale warp login + +# 检查凭据/缓存状态和诊断信息 +tokscale warp status + +# 将汇总请求数和消费同步到 tokscale 本地缓存 +tokscale warp sync + +# 移除已保存的凭据;添加 --purge-cache 可同时删除已同步的使用数据 +tokscale warp logout --purge-cache +``` + +**缓存位置**:`~/.config/tokscale/warp-cache/usage.json` + +**工作原理**:`tokscale warp sync` 调用 Warp 已认证的 GraphQL API,获取账号和工作区的汇总计数器。Tokscale 将请求数保留为消息计数,将供应商上报的消费金额保留为成本,但不会将请求数转换为合成 token。由于公开排行榜只接受基于 token 归因的使用量,Warp 数据默认不包含在 `submit` 提交内容中。 + +### 任务归因报告 + +`report` 命令会生成按任务归因的使用量分解。它使用 LLM 将每个会话总结为一个简短的标题和类别,然后将相关会话归并为高层级的任务集群,从而鸟瞰你的 Token 都花在了哪里。 + +```bash +# 基本报告(今天,默认使用 Apple FM 摘要器)。LLM 摘要默认开启。 +tokscale report + +# 最近 7 天 +tokscale report --week + +# 使用 Claude Code 作为摘要器后端 +tokscale report --week --summarizer claude + +# 使用 Codex、Gemini 或 Kiro +tokscale report --summarizer codex +tokscale report --summarizer gemini +tokscale report --summarizer kiro + +# 跳过 LLM 摘要(仅显示原始数据);这是退出(opt-out)选项 +tokscale report --no-summarize + +# 从头重新摘要(重置范围内已缓存的摘要) +tokscale report --week --rebuild + +# 以 JSON 输出 +tokscale report --week --json + +# 按工作区或客户端筛选 +tokscale report --workspace my-project --client opencode +``` + +**摘要器后端:** + +| 后端 | 命令 | 说明 | +|---------|---------|-------| +| `apple-fm` | (默认) | 通过原生 Rust FFI 在本地使用 Apple Foundation Models(无需 Python)。已在预构建的 Apple Silicon(macOS arm64)二进制中启用,在开启 Apple Intelligence 的 macOS 26 及以上系统运行;在其他环境(Intel Mac、更旧的 macOS、Linux、Windows)则透明回退至内置 Rust 启发式分类器,因此默认配置可在所有平台正常使用。 | +| `claude` | `claude -p` | 需要已安装并已认证的 Claude Code CLI。 | +| `codex` | `codex --quiet` | 需要已安装并已认证的 Codex CLI。 | +| `gemini` | `gemini -p` | 需要已安装并已认证的 Gemini CLI。 | +| `kiro` | `kiro --non-interactive` | 需要已安装并已认证的 Kiro CLI。 | + +**工作原理:** + +1. 扫描会话并将其插入本地 SQLite wiki 数据库(`wiki.db`,位于平台配置目录——Linux 上为 `~/.config/tokscale/`,macOS 上为 `~/Library/Application Support/tokscale/`) +2. 未摘要的会话分批发送到选定的 LLM 后端,后端为每个会话返回标题、类别、描述和复杂度 +3. 第二轮 LLM 处理将所有已加标题的会话归并为 3–8 个高层级任务集群(例如 "Kiro Auth"、"Tokscale Report"、"System Config") +4. 结果缓存在 wiki 数据库中 —— 后续运行会跳过已摘要的会话 + +**示例输出:** + +``` + Task Group Sess Tokens Cost + ─────────────────────────────────────────────────────────────────────── + Tokscale Development 19 4.2B $22.66 + Add task-attributed report command + Implement wiki DB schema + Fix pricing lookup for new models + System Config 28 2.1B $10.06 + Configure OpenCode workspace settings + Update shell aliases + Kiro Auth 4 890.5M $3.10 + Implement JWT refresh flow +``` + +### 订阅使用量 + +Tokscale 可以获取并显示您在各 AI 提供商上的实时订阅配额。它会显示您已使用了多少套餐额度,以及限额何时重置。 + +```bash +# 显示所有已检测到提供商的订阅使用量 +tokscale usage + +# 以 JSON 输出(用于脚本) +tokscale usage --json + +# 轻量终端输出(无 TUI) +tokscale usage --light +``` + +在 TUI 中,切换到 **Usage** 标签即可查看订阅数据。使用 `[Refresh]` 刷新订阅配额。键盘刷新快捷键 `r` 使用相同的刷新路径。 + +> **注意**:订阅配额和余额均为**供应商上报**——tokscale 调用每个提供商自己的配额端点,并原样呈现其返回结果。这些数字反映的是提供商上报的值(也就是其官方仪表板上显示的值),并未与 tokscale 自身的使用量跟踪进行独立核对。 + +#### 支持的提供商 + +| 提供商 | 认证方式 | 指标 | 设置 | +|----------|-------------|---------|-------| +| **Claude** | OAuth(凭据文件或 macOS 钥匙串) | Session(5 小时)、Weekly、Opus 配额 | 运行 `claude` 登录 | +| **Codex**(OpenAI) | OAuth(`~/.config/codex/auth.json`、`~/.codex/auth.json`,或已保存的 Tokscale 账号) | Session、Weekly 配额 | 在 TUI Usage 标签中使用 `[Add Codex]`,运行 `codex` 登录,或用 `tokscale codex import --name work` 导入现有认证 | +| **Z.ai** | API key(环境变量) | Token 限额、Web Searches | 设置 `ZAI_API_KEY` 或 `GLM_API_KEY` | +| **Amp** | API key(`~/.local/share/amp/secrets.json`) | 免费额度余额、Credits | 运行 `amp` 登录 | +| **GitHub Copilot** | GitHub token(钥匙串或 `~/.config/gh/hosts.yml`) | Premium interactions、Chat 配额 | 运行 `gh auth login` | +| **Grok Build** | OAuth(`~/.grok/auth.json`) | Credits、订阅套餐 | 运行 `grok login` | +| **Kimi** | OAuth(`~/.kimi/credentials/kimi-code.json`) | Session、Weekly 配额 | 运行 `kimi` 登录 | +| **MiniMax** | API key(环境变量) | 各模型的 Prompt 配额 | 设置 `MINIMAX_API_KEY` 或 `MINIMAX_API_TOKEN` | +| **MiniMax Token Plan** | API key(环境变量) | 区间 + 每周剩余百分比配额(按区域:CN minimaxi.com + Global minimax.io) | 设置 `MINIMAX_TOKEN_PLAN_CN_KEY` 和/或 `MINIMAX_TOKEN_PLAN_GLOBAL_KEY` | +| **Sakana**(Fugu) | 会话 cookie(环境变量或文件)—— 计费控制台 HTML 抓取,无公开 API | 5 小时、Weekly 配额窗口(套餐等级 + 月度价格作为元数据) | 设置 `SAKANA_SESSION_COOKIE`(参见 [docs/providers/sakana.md](docs/providers/sakana.md)) | + +提供商会被自动检测——仅显示具有有效凭据的提供商。如果缺少某个提供商,请确认您已登录或设置了所需的环境变量。 + +#### Codex 多账号使用量 + +Tokscale 可以保存多个 Codex OAuth 账号用于订阅使用量显示。TUI Usage 标签会将已保存的账号归并到一个 **Codex** 区块下。活动账号以 `*` 标记;非活动账号可通过 `[Use]` 选中;移除账号使用 `[Remove]` 后接 `[Confirm]`。 + +要在不离开 TUI 的情况下添加账号,请在 Usage 标签中点击 `[Add Codex]`。Tokscale 会用一个临时的 `CODEX_HOME` 启动 `codex login`,在 Usage 标签中显示登录输出,将生成的认证导入 Tokscale 的账号存储,然后刷新使用量。这样可保持登录隔离,且不会切换当前的 Codex 认证;当您希望 Tokscale 将某个已保存账号写入真正的 Codex 认证文件时,点击该账号上的 `[Use]`。 + +CLI 命令仍然可用于脚本化或手动的账号管理: + +```bash +# 将当前 Codex 认证保存为命名的 Tokscale 账号 +tokscale codex import --name work + +# 列出已保存的 Codex 账号 +tokscale codex accounts +tokscale codex accounts --json + +# 切换活动 Codex 账号并写入 Codex auth.json +tokscale codex switch work + +# 停止跟踪某个已保存的 Codex 账号(仅从 Tokscale 的存储中移除—— +# codex CLI 自身的 auth.json/登录状态永远不会被改动) +tokscale codex remove personal + +# 检查活动账号或指定账号的订阅使用量 +tokscale codex status +tokscale codex status --name personal --json +``` + +当存在已保存的 Codex 账号时,`tokscale usage --json` 会为每个 Codex 条目包含结构化的账号元数据,TUI 会将这些条目显示在一个 Codex 分组下。若无已保存的账号,Tokscale 会回退到当前的 Codex 认证发现路径(`CODEX_HOME/auth.json`、`~/.config/codex/auth.json`、`~/.codex/auth.json`,然后是 macOS 钥匙串)。 + +#### 示例输出 + +``` +╭──────────────────────────────────────────────────────────╮ +│ Session 85% left [=========---] resets in 2h 15m │ +│ Weekly 72% left [========----] resets Fri 3pm │ +│ Plan Max 20x │ +╰──────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ Session 40% left [=====-------] resets in 4h 30m │ +│ Weekly 90% left [==========--] resets Mon 12am │ +│ Account user@example.com │ +│ Plan Pro │ +╰──────────────────────────────────────────────────────────╯ +``` + ### 示例输出(`--light` 版本) CLI Light @@ -531,13 +829,25 @@ Tokscale 将设置存储在 `~/.config/tokscale/settings.json`: { "colorPalette": "blue", "includeUnusedModels": false, - "defaultClients": ["opencode", "claude"] + "defaultClients": ["opencode", "claude"], + "scanner": { + "extraScanPaths": { + "codex": [ + "/Users/me/workspace/project-a/.codex/sessions", + "/Users/me/workspace/project-b/.codex/archived_sessions" + ], + "hermes": [ + "/Users/me/.hermes/profiles/director_planning", + "/Users/me/.hermes/profiles/research/state.db" + ] + } + } } ``` | 设置 | 类型 | 默认值 | 描述 | |---------|------|---------|-------------| -| `colorPalette` | string | `"blue"` | TUI 颜色主题(green、halloween、teal、blue、pink、purple、orange、monochrome、ylgnbu) | +| `colorPalette` | string | `"blue"` | TUI 颜色主题(green、halloween、teal、blue、pink、purple、orange、monochrome、ylgnbu、graphite、lagoon、dusk) | | `includeUnusedModels` | boolean | `false` | 在报告中显示零 Token 的模型 | | `autoRefreshEnabled` | boolean | `false` | 在 TUI 中启用自动刷新 | | `autoRefreshMs` | number | `60000` | 自动刷新间隔(30000-3600000ms) | @@ -545,6 +855,11 @@ Tokscale 将设置存储在 `~/.config/tokscale/settings.json`: | `defaultClients` | string[] | `[]` | 未传递 `--client/-c` 选项时应用的客户端筛选。接受与 `--client` 相同的 ID(例如 `["opencode", "claude", "synthetic"]`)。未知 ID 会被静默丢弃。命令行选项会完全覆盖此列表 — 不会合并。 | | `light.writeCache` | boolean | `false` | 为 `true` 时,`tokscale --light` 会在渲染完成后以原子方式覆盖 TUI 缓存。CLI 标志 `--write-cache` / `--no-write-cache` 会按次运行覆盖该设置。 | | `minutelyTabEnabled` | boolean | `false` | 在 TUI 中显示按分钟的 Minutely 标签,并在数据加载期间执行分钟级聚合。对大多数用户而言,分钟级粒度是较为小众的诊断视图,而在大数据集上分钟分桶有非平凡的代价,因此默认关闭。 | +| `scanner.extraScanPaths` | object | `{}` | 针对 Tokscale 默认 home 根位置之外的会话,为各客户端额外指定的扫描根目录 | + +使用 `scanner.extraScanPaths` 配置持久化的额外根目录,例如项目级的 `.codex` 目录或导入的 Gemini/OpenClaw 历史。Tokscale 会自动发现 `$HERMES_HOME/profiles/*/state.db` 下的 Hermes 配置文件数据库(未设置 `HERMES_HOME` 时为 `~/.hermes/profiles/*/state.db`)。仅对非标准的 Hermes 配置文件位置使用 `scanner.extraScanPaths.hermes`;Hermes 条目既可以指向包含 `state.db` 的配置文件目录,也可以直接指向 `state.db` 文件。Tokscale 在每次运行时都会将这些路径与默认扫描根目录合并,并按规范路径去重重叠的根目录。 + +使用 `defaultClients` 固定一个个人默认值 —— 例如,如果您只使用 OpenCode 和 Claude,就将其设为 `["opencode", "claude"]`,那么 `tokscale`(不带任何选项)会自动将每个报告的范围限定为它们。在命令行传入 `--client` 可针对单次运行进行覆盖。 #### 启用 Minutely 标签 @@ -565,7 +880,7 @@ Minutely 标签按分钟显示 Token 使用情况,最适合用于诊断突发 可再生成的 CLI/TUI/价格/Wrapped 缓存位于 `~/.config/tokscale/cache/` 下(如果设置了 `TOKSCALE_CONFIG_DIR`,则为 `${TOKSCALE_CONFIG_DIR}/cache/`)。集成同步产物保留在各自的客户端缓存目录中,例如 `~/.config/tokscale/antigravity-cache/` 和 `~/.config/tokscale/trae-cache/`: - `tui-data-cache.json` —— TUI 启动缓存 -- `source-message-cache.bin` + `source-message-cache.lock` —— 源消息缓存与锁文件 +- `source-message-cache-v2/` + `source-message-cache.lock` —— 分片源消息缓存与锁文件 - `pricing-litellm.json` / `pricing-openrouter.json` —— 定价缓存 - `opencode-migration.json` —— OpenCode 迁移记录 - `fonts/`、`images/` —— Wrapped 资源缓存 @@ -579,14 +894,23 @@ Minutely 标签按分钟显示 Token 使用情况,最适合用于诊断突发 | 变量 | 默认值 | 描述 | |----------|---------|-------------| | `TOKSCALE_NATIVE_TIMEOUT_MS` | `300000`(5 分钟) | 覆盖 `nativeTimeoutMs` 配置 | +| `TOKSCALE_API_TOKEN` | unset | 用于非交互式 `submit` 和 `delete-submitted-data` 运行的 Tokscale 个人 API token。可从 Settings > API Tokens 创建一个,或用 `tokscale login --token tt_xxx` 保存到本地。 | +| `TOKSCALE_EXTRA_DIRS` | unset | 一次性的额外会话根目录,格式为 `client:/abs/path,client:/abs/path` | | `TOKSCALE_CONFIG_DIR` | unset | 覆盖配置目录根(`settings.json`、`star-cache.json`、`cache/`、`antigravity-cache/`、`trae-cache/` 的存放位置)。建议使用绝对路径;相对路径将基于进程 CWD 解析。适用于 CI 沙箱或固定到非默认位置。设置后,tokscale 不会回退到 macOS 旧路径(`~/Library/Application Support/tokscale/`)。 | +| `TOKSCALE_FM_DEBUG` | unset | 设置后,会将 Apple Foundation Models 的诊断信息(macOS 版本门槛、dlopen dylib 路径、加载/符号错误)打印到 stderr,以说明本机端 apple-fm 为何启用或未启用。 | ```bash # 示例:为非常大的数据集增加超时时间 TOKSCALE_NATIVE_TIMEOUT_MS=600000 tokscale graph --output data.json + +# 示例:一次性的额外扫描根目录 +TOKSCALE_EXTRA_DIRS='codex:/Users/me/workspace/project-a/.codex/sessions,gemini:/Users/me/imports/imac/gemini/tmp' tokscale + +# 示例:在 CI 中提交,无需交互式浏览器登录 +TOKSCALE_API_TOKEN=tt_xxx tokscale submit ``` -> **注意**:如需永久更改,建议在 `~/.config/tokscale/settings.json` 中设置 `nativeTimeoutMs`。环境变量适用于一次性覆盖或 CI/CD。 +> **注意**:对于持久化的额外根目录,建议在 `~/.config/tokscale/settings.json` 中使用 `scanner.extraScanPaths`。`TOKSCALE_EXTRA_DIRS` 最适合一次性覆盖或 CI/CD。 ### Headless 模式 @@ -662,7 +986,7 @@ tokscale sources --json - **交互式提示**:悬停查看详细的每日分解 - **每日分解面板**:点击查看每个来源和模型的详情 - **年份筛选**:在年份之间导航 -- **来源筛选**:按平台筛选(OpenCode、Claude、Codex、Copilot、Cursor、Gemini、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi、Qwen、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Zed、Kiro、Trae、Gajae-Code、Synthetic) +- **来源筛选**:按平台筛选(OpenCode、Claude、Codex、Copilot、Cursor、Gemini、Amp、Codebuff、Droid、OpenClaw、Hermes Agent、Pi、Kimi、Qwen、Roo Code、Kilo、Mux、Kilo CLI、Crush、Goose、Antigravity、Antigravity CLI、Zed、Kiro、Trae、Warp、Cline、Gajae-Code、Grok Build、Jcode、MiMo Code、Command Code、Junie、ZCode、Synthetic) - **统计面板**:总成本、Token、活跃天数、连续记录 - **FOUC 防护**:在 React 水合前应用主题(无闪烁) @@ -696,13 +1020,27 @@ Tokscale 包含一个社交平台,您可以在其中分享使用数据并与 [![Tokscale Stats](https://tokscale.ai/api/embed//svg)](https://tokscale.ai/u/) ``` -- 将 `` 替换为您的 GitHub 用户名 -- 可选查询参数: - - `theme=light` 使用浅色主题 - - `sort=tokens`(默认)或 `sort=cost` 控制排名依据 - - `compact=1` 使用紧凑布局 + 紧凑数字表示法(例如 `1.2M`、`$3.4K`) -- 示例: - - `https://tokscale.ai/api/embed//svg?theme=light&sort=cost&compact=1` +将 `` 替换为您的 GitHub 用户名。不带任何查询参数时,渲染默认的 `classic` 卡片;追加下面的任意参数即可自定义设计。 + +| 参数 | 取值 | 效果 | +| --- | --- | --- | +| `template` | `classic`(默认)· `minimal` · `terminal` · `graph` · `orbit` · `vitals` · `blueprint` · `receipt` | 卡片设计 | +| `color` | `blue` · `green` · `teal` · `purple` · `pink` · `orange` · `monochrome` · `halloween` · `YlGnBu` | 强调色和贡献图配色 | +| `theme` | `dark`(默认)· `light` | 浅色或深色卡片 | +| `sort` | `tokens`(默认)· `cost` | 排名取自哪个排行榜 | +| `tokens`、`cost` | `compact` · `full` | 数字格式,可分别设置 —— `20.9B` 对比 `20,941,000,000` | +| `rank` | `plain`(默认,`#134`)· `percent`(`top 12%`)· `total`(`#134 / 1,174`) | 排行榜名次的显示方式 | +| `graph` | `1` 表示追加贡献图(默认关闭) | `classic`、`minimal`、`terminal`、`orbit`、`blueprint`、`receipt` 支持 | +| `compact` | `1` 表示紧凑布局 | 仅 `classic` | + +示例: + +```md +![](https://tokscale.ai/api/embed//svg?template=minimal&color=purple&graph=1) +![](https://tokscale.ai/api/embed//svg?template=orbit&color=pink&rank=percent) +![](https://tokscale.ai/api/embed//svg?template=terminal&color=green&theme=light) +![](https://tokscale.ai/api/embed//svg?template=receipt&color=YlGnBu&graph=1) +``` ### GitHub 个人资料徽章 @@ -726,7 +1064,7 @@ Tokscale 包含一个社交平台,您可以在其中分享使用数据并与 ### 入门 -1. **登录** - 运行 `tokscale login` 通过 GitHub 认证 +1. **登录** - 运行 `tokscale login` 通过 GitHub 认证,或在 Settings 中创建一个 API token 供 CI/无头环境使用 2. **提交** - 运行 `tokscale submit` 上传使用数据 3. **查看** - 访问 Web 平台查看您的资料和排行榜 @@ -921,16 +1259,16 @@ cd packages/core && bun run bench ### 原生模块目标 -| 平台 | 架构 | 状态 | -|----------|--------------|--------| -| macOS | x86_64 | ✅ 支持 | -| macOS | aarch64(Apple Silicon) | ✅ 支持 | -| Linux | x86_64(glibc) | ✅ 支持 | -| Linux | aarch64(glibc) | ✅ 支持 | -| Linux | x86_64(musl) | ✅ 支持 | -| Linux | aarch64(musl) | ✅ 支持 | -| Windows | x86_64 | ✅ 支持 | -| Windows | aarch64 | ✅ 支持 | +| 平台 | 架构 | +|----------|--------------| +| macOS | x86_64 | +| macOS | aarch64(Apple Silicon) | +| Linux | x86_64(glibc) | +| Linux | aarch64(glibc) | +| Linux | x86_64(musl) | +| Linux | aarch64(musl) | +| Windows | x86_64 | +| Windows | aarch64 | ### Windows 支持 @@ -963,17 +1301,30 @@ AI 编程工具将会话数据存储在跨平台位置。大多数工具在所 | Droid | `~/.factory/` | `%USERPROFILE%\.factory\` | 所有平台使用相同路径 | | Pi | `~/.pi/` and `~/.omp/` | `%USERPROFILE%\.pi\` and `%USERPROFILE%\.omp\` | 所有平台使用相同路径(支持 Pi 和 [Oh My Pi](https://github.com/can1357/oh-my-pi)) | | Kimi CLI | `~/.kimi/` | `%USERPROFILE%\.kimi\` | 所有平台使用相同路径 | +| Kimi Code | `~/.kimi-code/` | `%USERPROFILE%\.kimi-code\` | 所有平台使用相同路径 | | Qwen CLI | `~/.qwen/` | `%USERPROFILE%\.qwen\` | 所有平台使用相同路径 | | Roo Code | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\rooveterinaryinc.roo-cline\tasks\` | VS Code globalStorage 任务日志 | | Kilo | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/` | `%USERPROFILE%\.config\Code\User\globalStorage\kilocode.kilo-code\tasks\` | VS Code globalStorage 任务日志 | +| Cline | Linux: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/`;macOS: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/`;server: `~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/` | `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\tasks\` | VS Code globalStorage 任务日志 | | Mux | `~/.mux/sessions/` | `%USERPROFILE%\.mux\sessions\` | 所有平台相同路径 | | Codebuff | `~/.config/manicode/projects/`(+ `manicode-dev`、`manicode-staging`) | `%USERPROFILE%\.config\manicode\projects\` | 通过 `CODEBUFF_DATA_DIR` 环境变量覆盖 | | Kilo CLI | `~/.local/share/kilo/` | `%USERPROFILE%\.local\share\kilo\` | 与 OpenCode 一样使用 `xdg-basedir` | | Crush | `$XDG_DATA_HOME/crush/`(回退路径:`~/.local/share/crush/`) | `%USERPROFILE%\.local\share\crush\`(如果设置了 `%XDG_DATA_HOME%`,则为 `%XDG_DATA_HOME%\crush\`) | 使用带回退路径的 XDG 数据目录 | | Goose | `~/.local/share/goose/sessions/`(+ macOS Application Support、旧版 Block 路径) | `%USERPROFILE%\.local\share\goose\sessions\` | 可通过 `GOOSE_PATH_ROOT` 环境变量配置 | | Antigravity | `~/.config/tokscale/antigravity-cache/sessions/` | — | `tokscale antigravity sync` 目前仅支持 macOS / Linux | +| Zed Agent | `~/.local/share/zed/threads/threads.db` | `%LOCALAPPDATA%\Zed\threads\threads.db` | 仅限托管 Zed 模型的使用量;不含外部 ACP 代理 | +| Kiro | `~/.kiro/sessions/cli/` 和 `~/.local/share/kiro-cli/data.sqlite3` | `%USERPROFILE%\.kiro\sessions\cli\` 和 `%USERPROFILE%\.local\share\kiro-cli\data.sqlite3` | 解析 Kiro 会话文件,以及存在时的 Kiro CLI SQLite 数据库 | | Trae | `~/.config/tokscale/trae-cache/sessions/` | `%APPDATA%\tokscale\trae-cache\sessions\` | 通过 `tokscale trae sync` 同步一次;凭据会从已安装的任意 Trae IDE 或 Trae Solo 桌面端自动发现 | +| Warp/Oz | `~/.config/tokscale/warp-cache/usage.json` | `%APPDATA%\tokscale\warp-cache\usage.json` | 通过 `tokscale warp sync` 同步;仅汇总请求数和消费金额,不含 token 转录 | +| Grok Build | `~/.grok/sessions/` | `%USERPROFILE%\.grok\sessions\` | 可通过 `GROK_HOME` 环境变量配置;解析 `updates.jsonl` 会话更新 | +| Jcode | `~/.jcode/sessions/` | `%USERPROFILE%\.jcode\sessions\` | 可通过 `JCODE_HOME` 环境变量配置;解析 `session_*.json` 快照以及 `session_*.journal.jsonl` sidecar | +| MiMo Code | `~/.local/share/mimocode/` | `%USERPROFILE%\.local\share\mimocode\` | 使用 XDG 数据目录;SQLite 数据库 `mimocode.db` | | Gajae-Code | `~/.gjc/agent/sessions/` | `%USERPROFILE%\.gjc\agent\sessions\` | 可通过 `GJC_CODING_AGENT_DIR`(也可用 `GJC_CONFIG_DIR`/`PI_CONFIG_DIR`;Linux/macOS 上 `$XDG_DATA_HOME/gjc/sessions/` 亦支持)配置 | +| Junie | `~/.junie/sessions/` | `%USERPROFILE%\.junie\sessions\` | 所有平台使用相同的 home 相对路径;解析 `events.jsonl` 使用事件 | +| ZCode | `~/.zcode/cli/db/db.sqlite` 和 `~/.zcode/projects/` | `%USERPROFILE%\.zcode\cli\db\db.sqlite` 和 `%USERPROFILE%\.zcode\projects\` | 解析 v2 SQLite 模型用量和旧版 `*.jsonl` 会话记录;Z.ai 的 GLM 模型专用 ADE | +| OpenCodeReview | `~/.opencodereview/sessions/` | `%USERPROFILE%\.opencodereview\sessions\` | 解析 `*.jsonl` 会话记录;阿里巴巴的 AI 代码审查工具 | +| CodeBuddy | `~/.codebuddy/projects/` + 扩展日志 | `%USERPROFILE%\.codebuddy\projects\` + CodeBuddy / VS Code 扩展日志 | 解析 CodeBuddy CLI、IDE 和 VS Code 插件的 token 用量 | +| WorkBuddy | `~/.workbuddy/projects/` + `~/.workbuddy/workbuddy.db` | `%USERPROFILE%\.workbuddy\projects\` + `%USERPROFILE%\.workbuddy\workbuddy.db` | 解析 WorkBuddy token 用量,以聚合 SQLite 数据库作为回退 | | Synthetic | 从其他来源重归属 | 从其他来源重归属 | 检测 `hf:` 模型前缀 + `synthetic` provider | > **注意**:在 Windows 上,`~` 扩展为 `%USERPROFILE%`(例如 `C:\Users\用户名`)。这些工具故意使用 Unix 风格的路径(如 `.local/share`)而不是 Windows 原生路径(如 `%APPDATA%`),以实现跨平台一致性。 @@ -1067,6 +1418,44 @@ OpenCode 1.2+ 将会话存储在 SQLite 中。Tokscale 优先从 SQLite 读取 OpenCode 根据构建时的发布渠道决定数据库文件名:`latest`/`beta` 渠道使用 `opencode.db`,其他渠道使用 `opencode-.db`(例如 `opencode-stable.db`、`opencode-nightly.db`)。Tokscale 会扫描所有这些文件,因此同时使用多个渠道的用户也能获得统一的视图。 +如果您用 `OPENCODE_DB` 指向 `~/.local/share/opencode` 之外的文件来启动 opencode,请将该绝对路径添加到 `~/.config/tokscale/settings.json`,以便 tokscale 每次运行都能找到它: + +```json +{ + "scanner": { + "opencodeDbPaths": [ + "/custom/location/opencode.db", + "/another/location/opencode-stable.db" + ] + } +} +``` + +这些路径会与自动发现结果合并,按规范路径去重,不存在的条目会被静默跳过(因此过时的配置不会让扫描中断)。`opencode.db-wal`、`opencode.db-shm` 及其他 SQLite sidecar 会被拒绝。 + +如果您将会话保存在 Tokscale 默认 home 根位置之外,也可以为各客户端持久化额外的扫描根目录: + +```json +{ + "scanner": { + "extraScanPaths": { + "codex": [ + "/Users/me/workspace/project-a/.codex/sessions", + "/Users/me/workspace/project-b/.codex/archived_sessions" + ], + "gemini": ["/Users/me/imports/imac/gemini/tmp"], + "hermes": [ + "/Users/me/.hermes/profiles/director_planning", + "/Users/me/.hermes/profiles/research/state.db" + ], + "openclaw": ["/Users/me/imports/imac/openclaw/agents"] + } + } +} +``` + +这对于项目级的 `.codex` 目录、导入的历史,以及默认 `$HERMES_HOME/state.db` 或 `~/.hermes/state.db` 位置之外的 Hermes 配置文件数据库都很有用。Tokscale 仍会扫描其默认根目录,然后在其上合并 `scanner.extraScanPaths` 和 `TOKSCALE_EXTRA_DIRS`,并按规范路径去重。它不会自动发现您的整个工作区。 + 每个消息包含: ```json { @@ -1086,13 +1475,17 @@ OpenCode 根据构建时的发布渠道决定数据库文件名:`latest`/`beta ### Claude Code -位置:`~/.claude/projects/{projectPath}/*.jsonl` +位置:`~/.claude/projects/{projectPath}/*.jsonl` 和 `~/.claude/transcripts/*.jsonl` 包含使用数据的助手消息的 JSONL 格式: ```json {"type": "assistant", "message": {"model": "claude-sonnet-4-20250514", "usage": {"input_tokens": 1234, "output_tokens": 567, "cache_read_input_tokens": 890}}, "timestamp": "2024-01-01T00:00:00Z"} ``` +`~/.claude/transcripts/` 下的包装转录文件仅在包含真实 Claude 使用量元数据时才会被统计。包含用户/工具事件但没有 `usage` 块的文件会被跳过,而不会进行估算。 + +Tokscale 的 `claude` 客户端统计的是 Claude Code 的 Token,而非 Claude Desktop 聊天。Claude Desktop 会将应用数据存储在 `~/Library/Application Support/Claude` 等位置,但 Anthropic 并未为消费级桌面聊天或聊天历史导出提供文档化、稳定的本地按消息 Token 账本。当存在 Claude Desktop 数据但只有 Claude Code JSONL 根目录可扫描时,运行 `tokscale clients` 可看到一条诊断信息。`tokscale usage` 可以根据 Claude Code 凭据尽力显示 Claude 订阅配额条,而组织/API 使用量属于 Anthropic 的 Admin Usage 和 Cost API,与本地转录扫描有意分离。 + ### Codex CLI 位置:`~/.codex/sessions/*.jsonl` @@ -1151,9 +1544,9 @@ Tokscale 将 `chat` span 作为 Token 统计的真实来源,并在第一阶段 ### Cursor IDE -位置:`~/.config/tokscale/cursor-cache/`(通过 Cursor API 同步) +位置:`~/.config/tokscale/cursor-cache/usage*.csv`(通过 Cursor API 同步) -Cursor 数据使用您的会话令牌从 Cursor API 获取并本地缓存。运行 `tokscale cursor login` 进行认证。设置说明请参阅 [Cursor IDE 命令](#cursor-ide-命令)。 +Cursor 数据使用您的会话令牌从 Cursor API 获取并本地缓存。Tokscale 读取这些缓存文件来生成报告;它不会解析本地的 `~/.cursor` 会话数据。设置说明请参阅 [Cursor IDE 命令](#cursor-ide-命令)。 ### Antigravity @@ -1167,6 +1560,24 @@ Antigravity 数据不会被根命令自动获取。请在启用了 Antigravity Trae 数据不会被根命令自动获取。先运行一次 `tokscale trae login`,然后在生成报告前运行 `tokscale trae sync`。Tokscale 会将同步得到的 API dump 解析为会话级记录,并保留 Trae 返回的成本总额。 +### Warp/Oz + +位置:`~/.config/tokscale/warp-cache/usage.json`(通过已认证的 GraphQL API 同步) + +Warp/Oz 数据不会被根命令自动获取。先运行 `tokscale warp login`,然后在生成报告前运行 `tokscale warp sync`。由于 Warp 不暴露基于 token 归因的本地转录,Tokscale 仅记录汇总的请求数和消费金额。 + +### Grok Build + +位置:`$GROK_HOME/sessions/*/*/updates.jsonl`(回退:`~/.grok/sessions/*/*/updates.jsonl`) + +Grok Build 数据直接从本地会话更新解析。当前日志只公开累积 `totalTokens` 计数器,没有稳定的 input/output 拆分,因此 Tokscale 将每个 turn 的正向增量记录为 input token。`grok-composer-2.5-fast` 会临时映射到 Composer 2.5 Fast 价格 override,直到专用公开价格可用。 + +### Jcode + +位置:`$JCODE_HOME/sessions/session_*.json`(回退:`~/.jcode/sessions/session_*.json`)以及匹配的 `session_*.journal.jsonl` sidecar。 + +Jcode 数据直接从本地会话快照解析。Tokscale 读取助手消息的 `messages[].token_usage` 字段(`input_tokens`、`output_tokens`、`cache_read_input_tokens`、`cache_creation_input_tokens` 和 `reasoning_output_tokens`),不会伪造其他客户端的身份。匹配的 journal sidecar 会在去重前合并进同一会话流,因此在 Jcode 将其检查点写入快照之前,最近追加的消息也会被包含进来。去重使用稳定的消息 ID 进行重放去重;缺少 ID 的畸形/自定义记录则使用作用域内的回退 key。 + ### OpenClaw 位置:`~/.openclaw/agents/*/sessions/sessions.json`(也扫描旧版路径:`~/.clawdbot/`、`~/.moltbot/`、`~/.moldbot/`) @@ -1189,7 +1600,7 @@ Trae 数据不会被根命令自动获取。先运行一次 `tokscale trae login ### Hermes Agent -位置:`$HERMES_HOME/state.db`(回退:`~/.hermes/state.db`) +位置:`$HERMES_HOME/state.db`(回退:`~/.hermes/state.db`),以及位于 `$HERMES_HOME/profiles/*/state.db` 的标准配置文件数据库(当 `HERMES_HOME` 指向活动配置文件时,则为同级的 `~/.hermes/profiles/*/state.db`) Hermes 将会话级使用量存储在 SQLite `sessions` 表中。Tokscale 导入 `model` 存在且 token 或费用合计非零的行,使用 `started_at` 作为时间戳,保留 `message_count`,并优先使用 `actual_cost_usd` 而非 `estimated_cost_usd`。 @@ -1213,6 +1624,13 @@ Hermes 将会话级使用量存储在 SQLite `sessions` 表中。Tokscale 导入 {"timestamp": 1770983426.420942, "message": {"type": "StatusUpdate", "payload": {"token_usage": {"input_other": 1562, "output": 2463, "input_cache_read": 0, "input_cache_creation": 0}, "message_id": "chatcmpl-xxx"}}} ``` +### Kimi Code + +位置: `~/.kimi-code/sessions/{WORKDIR}/{SESSION_UUID}/agents/{AGENT}/wire.jsonl` +```json +{"type":"usage.record","model":"kimi-code/kimi-for-coding","usage":{"inputOther":1163,"output":352,"inputCacheRead":22272,"inputCacheCreation":0},"usageScope":"turn","time":1780410897480} +``` + ### Qwen CLI 位置:`~/.qwen/projects/{PROJECT_PATH}/chats/{CHAT_ID}.jsonl` @@ -1258,6 +1676,19 @@ Kilo 使用与 Roo Code 相同的任务日志格式。Tokscale 应用相同的 - 从 `text` JSON 中解析 `tokensIn`、`tokensOut`、`cacheReads`、`cacheWrites`、`cost` 和 `apiProtocol` - 在可用时从相邻的 `api_conversation_history.json` 中丰富模型/代理元数据 +### Cline + +位置: +- Linux 桌面版 VS Code:`~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` +- macOS 桌面版 VS Code:`~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` +- Windows 桌面版 VS Code:`%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\tasks\{TASK_ID}\ui_messages.json` +- 服务器(尽力而为):`~/.vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/{TASK_ID}/ui_messages.json` + +Cline 是 Roo Code 和 Kilo 从中 fork 的上游项目,因此使用相同的 VS Code globalStorage 任务日志格式。Tokscale 应用相同的规则: +- 仅计算 `ui_messages.json` 中的 `say/api_req_started` 事件 +- 从 `text` JSON 中解析 `tokensIn`、`tokensOut`、`cacheReads`、`cacheWrites`、`cost` 和 `apiProtocol` +- 在可用时从相邻的 `api_conversation_history.json` 中丰富模型/代理元数据 + ### Mux 位置: @@ -1308,14 +1739,51 @@ Synthetic 通过后处理重归属其他来源的消息。当检测到 `hf:` 前 Tokscale 还会检测 `~/.local/share/octofriend/sqlite.db`,并在可用时解析包含 token 数据的记录。 +### MiMo Code + +位置:`~/.local/share/mimocode/mimocode.db`(XDG 数据目录) + +MiMo Code 将会话数据存储在 SQLite 数据库中。Tokscale 查询 `message` 表并关联 `session` 表获取工作区上下文: + +```sql +SELECT m.id, m.session_id, m.data, NULLIF(s.directory, '') AS workspace_root +FROM message m +LEFT JOIN session s ON s.id = m.session_id +WHERE json_extract(m.data, '$.role') = 'assistant' + AND json_extract(m.data, '$.tokens') IS NOT NULL +``` + +`data` 列为 JSON 格式,包含以下 token 相关字段: +```json +{ + "role": "assistant", + "modelID": "claude-sonnet-4", + "providerID": "anthropic", + "cost": 0.0032, + "tokens": { + "input": 1200, + "output": 450, + "reasoning": 0, + "cache": { "read": 800, "write": 0 } + }, + "time": { "created": 1780410897000, "completed": 1780410912000 }, + "agent": "micode", + "path": { "root": "/Users/me/project" } +} +``` + +Tokscale 使用时间戳、模型、provider、token 计数、成本和 agent 名称的指纹对跨 fork 会话的消息进行去重。 + ## 定价 Tokscale 从 [LiteLLM 的价格数据库](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)获取实时价格。 -**动态回退**:对于 LiteLLM 中尚未收录的模型(例如最近发布的模型),Tokscale 会自动从 [OpenRouter 的端点 API](https://openrouter.ai/docs/api/api-reference/endpoints/list-endpoints) 获取定价。 +**动态回退**:对于 LiteLLM 中尚未收录的模型(例如最近发布的模型),Tokscale 会自动从 [OpenRouter 的端点 API](https://openrouter.ai/docs/api/api-reference/endpoints/list-endpoints) 获取定价。这确保您无需等待 LiteLLM 更新,就能从模型的作者提供商(例如 glm-4.7 的 Z.AI)获得准确的价格。 **Cursor 模型定价**:对于 LiteLLM 和 OpenRouter 中都尚未收录的最新模型(例如 `gpt-5.3-codex`),Tokscale 使用从 [Cursor 模型文档](https://cursor.com/en-US/docs/models)获取的硬编码定价。这些覆盖在所有上游来源之后、模糊匹配之前检查,因此当真正的上游定价可用时会自动让步。 +**Sakana Fugu 定价**:Fugu Ultra 的成本根据 Sakana 公布的按量付费(pay-as-you-go)费率估算;`fugu` 路由模型有意不定价,因为它的成本就是其所编排的底层模型的浮动费率。 + **缓存**:价格数据以 1 小时 TTL 缓存到磁盘,确保快速启动: - LiteLLM 缓存:`~/.config/tokscale/cache/pricing-litellm.json` - OpenRouter 缓存:`~/.config/tokscale/cache/pricing-openrouter.json`(缓存支持提供商的模型作者定价信息) @@ -1326,7 +1794,7 @@ Tokscale 从 [LiteLLM 的价格数据库](https://github.com/BerriAI/litellm/blo - 缓存读取 Token(折扣) - 缓存写入 Token - 推理 Token(用于 o1 等模型) -- 分层定价(200k Token 以上) +- 模型专属的分层定价(例如 200k 或 272k Token 以上) ## 贡献 diff --git a/crates/tokscale-cli/Cargo.toml b/crates/tokscale-cli/Cargo.toml index c673f8a38..9cb40b60d 100644 --- a/crates/tokscale-cli/Cargo.toml +++ b/crates/tokscale-cli/Cargo.toml @@ -11,6 +11,12 @@ description = "CLI and TUI for tokscale - AI token usage analytics" name = "tokscale" path = "src/main.rs" +[features] +# Optional: native FFI into Apple's FoundationModels (on-device Apple Intelligence) +# for the `apple-fm` report summarizer backend. macOS-only; no-op elsewhere. +# NOT enabled by default — the default build degrades to a Rust heuristic. +apple-fm = [] + [dependencies] tokscale-core = { workspace = true } @@ -31,6 +37,7 @@ dirs = { workspace = true } rayon = { workspace = true } arboard = { workspace = true } reqwest = { workspace = true } +fs2 = { workspace = true } image = "0.25" imageproc = "0.25" ab_glyph = "0.2" @@ -42,6 +49,7 @@ uuid = { version = "1.0", features = ["v4"] } rpassword = "7.0" sha2 = "0.10" csv = "1.3" +unicode-normalization = "0.1" # Trae iCubeAuthInfo decryption (Electron globalStorage) aes = { workspace = true } diff --git a/crates/tokscale-cli/build.rs b/crates/tokscale-cli/build.rs new file mode 100644 index 000000000..37bb0ee47 --- /dev/null +++ b/crates/tokscale-cli/build.rs @@ -0,0 +1,149 @@ +//! Build script for tokscale-cli. +//! +//! When (and only when) the optional `apple-fm` feature is enabled AND the +//! target OS is macOS, this builds the vendored `foundation-models-c` SwiftPM +//! package as a DYNAMIC `libFoundationModels.dylib` and stages it next to the +//! final binary. +//! +//! The dylib is deliberately NOT linked into `tokscale`. Apple's +//! `FoundationModels.framework` only exists on macOS 26+, and the Swift runtime +//! the dylib pulls in (e.g. `libswiftSynchronization`, macOS 15+) does too; +//! hard-linking any of them would make the *whole* CLI fail to `dyld`-load on +//! older macOS — a crash-on-launch for every command, not a feature fallback. +//! Worse, `import FoundationModels` autolinks the framework as a NON-weak load +//! command, so a `-weak_framework` flag can't reliably flip it. +//! +//! Instead the binary links nothing FM/Swift (verifiable: `otool -L tokscale` +//! shows no FoundationModels and no libswift*), and the `apple-fm` code path +//! `dlopen`s this dylib lazily at runtime — only on macOS 26+, where all its +//! dependencies are present. On older macOS the `dlopen` simply fails and the +//! caller degrades to the cross-platform Rust heuristic. This keeps a SINGLE +//! arm64 binary safe to ship to every Apple Silicon Mac via npm. +//! +//! When the feature is off, or the target is not macOS, this build script is a +//! complete no-op so that cross-platform / default builds are unaffected. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + // Re-run only when the feature flag toggles. (Cheap; keeps the no-op path no-op.) + println!("cargo:rerun-if-env-changed=CARGO_FEATURE_APPLE_FM"); + + let feature_enabled = std::env::var("CARGO_FEATURE_APPLE_FM").is_ok(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + // No-op unless the feature is enabled and we're building for macOS. + if !feature_enabled || target_os != "macos" { + return; + } + + build_apple_fm(); +} + +fn build_apple_fm() { + let manifest_dir = + std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by cargo"); + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set by cargo"); + + let pkg_dir = Path::new(&manifest_dir).join("vendor/foundation-models-c"); + if !pkg_dir.join("Package.swift").exists() { + panic!( + "apple-fm feature is enabled but the vendored SwiftPM package was not found at {}. \ + Expected Package.swift there.", + pkg_dir.display() + ); + } + + // Re-run if any vendored Swift source, the manifest, the header, or this + // build script changes. + println!("cargo:rerun-if-changed=build.rs"); + println!( + "cargo:rerun-if-changed={}", + pkg_dir.join("Package.swift").display() + ); + println!( + "cargo:rerun-if-changed={}", + pkg_dir.join("Sources").display() + ); + + // Build the DYNAMIC `FoundationModels` product (`libFoundationModels.dylib`) + // in release mode. We do not build/link the static archive: the dylib is + // loaded at runtime via `dlopen`, so nothing FM/Swift ends up in the + // tokscale binary's load commands. + let status = Command::new("swift") + .args([ + "build", + "-c", + "release", + "--product", + "FoundationModels", + "--package-path", + ]) + .arg(&pkg_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "apple-fm feature is enabled but `swift build` could not be spawned: {e}. \ + Is the Swift toolchain installed and on PATH?" + ) + }); + + if !status.success() { + panic!( + "apple-fm feature is enabled but `swift build -c release` failed in {} \ + (exit status: {status}). Fix the Swift build or disable the apple-fm feature.", + pkg_dir.display() + ); + } + + let lib_name = "libFoundationModels.dylib"; + let built_lib = pkg_dir.join(".build/release").join(lib_name); + if !built_lib.exists() { + panic!( + "apple-fm: swift build succeeded but {} was not found", + built_lib.display() + ); + } + + // 1) Copy into OUT_DIR and bake its absolute path into the binary as a + // fallback. This is what `cargo test` / `cargo run` from arbitrary CWDs + // resolve to (the test harness binary lives in target//deps, so + // a sibling-of-exe copy alone would not be found there). + let out_lib = Path::new(&out_dir).join(lib_name); + copy(&built_lib, &out_lib); + println!("cargo:rustc-env=TOKSCALE_FM_DYLIB={}", out_lib.display()); + + // 2) Stage a copy NEXT TO the final binary, so the primary runtime lookup + // (`current_exe()`'s directory) succeeds for both `cargo run` and the + // shipped npm package, where the dylib travels alongside `tokscale`. + // + // OUT_DIR is `.../target///build/-/out`; + // ascending three parents lands on the profile dir that holds the binary. + if let Some(profile_dir) = profile_dir_from_out(&out_dir) { + let staged = profile_dir.join(lib_name); + copy(&built_lib, &staged); + // CI's release step copies this sibling dylib into the npm package's + // bin/ next to tokscale; surface its path for that step / debugging. + println!("cargo:warning=apple-fm: staged {}", staged.display()); + } +} + +/// `...//build/-/out` -> `.../`. +fn profile_dir_from_out(out_dir: &str) -> Option { + Path::new(out_dir) + .parent() // - + .and_then(Path::parent) // build + .and_then(Path::parent) // + .map(Path::to_path_buf) +} + +fn copy(from: &Path, to: &Path) { + std::fs::copy(from, to).unwrap_or_else(|e| { + panic!( + "apple-fm: failed to copy {} -> {}: {e}", + from.display(), + to.display() + ) + }); +} diff --git a/crates/tokscale-cli/src/commands/apple_fm.rs b/crates/tokscale-cli/src/commands/apple_fm.rs new file mode 100644 index 000000000..cabc4afee --- /dev/null +++ b/crates/tokscale-cli/src/commands/apple_fm.rs @@ -0,0 +1,973 @@ +//! Apple FoundationModels (on-device Apple Intelligence) summarizer backend. +//! +//! This module replaces the former `scripts/wiki-summarizer.py` Python backend +//! with native Rust FFI into Apple's FoundationModels via the vendored +//! `foundation-models-c` C-ABI package. +//! +//! The real FFI implementation is gated behind `cfg(all(target_os = "macos", +//! feature = "apple-fm"))`. On every other target/feature combination a stub +//! `summarize` returning `None` is compiled so the caller transparently falls +//! back to the Rust heuristic ([`heuristic_classify`]), which is always +//! available and cross-platform. +//! +//! ## Why `dlopen` instead of linking +//! +//! `FoundationModels.framework` only exists on macOS 26+, and the Swift runtime +//! the shim drags in (`libswiftSynchronization`, macOS 15+, etc.) does too. If +//! the `tokscale` binary hard-linked any of them it would fail to `dyld`-load on +//! older macOS — a crash-on-launch for EVERY command, not a feature fallback. +//! And `import FoundationModels` autolinks the framework as a non-weak load +//! command, so a `-weak_framework` flag can't reliably flip it. +//! +//! So the binary links nothing FM/Swift (verifiable: `otool -L tokscale` shows +//! no FoundationModels and no `libswift*`). The vendored shim is built as a +//! DYNAMIC `libFoundationModels.dylib` (see `build.rs`) staged next to the +//! binary, and this module `dlopen`s it lazily — only on macOS 26+, where all +//! its dependencies exist. On older macOS the `dlopen` simply fails and the +//! caller degrades to the heuristic. This keeps a SINGLE arm64 binary safe to +//! ship to every Apple Silicon Mac via npm, regardless of their macOS version. +//! +//! Availability gate: [`summarize`] returns `None` (never errors) when the +//! dylib can't be loaded (old macOS / missing file) OR Apple Intelligence is +//! unavailable, so the caller degrades to the heuristic. +//! +//! Smoke-testing note: the vendored `fm-c-example` binary's streaming path +//! (`FMLanguageModelSessionStreamResponse`) hard-segfaults (EXC_BAD_ACCESS in +//! `objc_retain`) on macOS 26.2, so it is NOT a valid liveness check for "is FM +//! working on this box". This module uses only the non-streaming +//! `FMLanguageModelSessionRespondWithSchema` path (a PROGRAMMATIC +//! GenerationSchema built via [`imp::build_schema`], NOT the JSON-Schema-string +//! `...FromJSON` variant) and is unaffected. For end-to-end verification use the +//! `#[ignore]`d live test in this module (`live_summarize_smoke`), not the +//! streaming example. + +/// Input metadata for one coding session to be summarized. +/// +/// Some fields (`client`, `first_user_message`, `message_count`) feed only the +/// FM prompt, so they are unread on the heuristic-only (feature-off / non-macOS) +/// build path. +#[cfg_attr(not(all(target_os = "macos", feature = "apple-fm")), allow(dead_code))] +#[derive(Debug, Clone)] +pub struct SessionInput { + pub session_id: String, + pub client: String, + pub workspace: String, + pub first_user_message: Option, + pub models_used: Vec, + pub total_tokens: i64, + pub duration_minutes: i64, + pub message_count: i64, +} + +/// Structured summary produced for one session. +#[derive(Debug, Clone)] +pub struct SessionSummary { + pub session_id: String, + pub title: String, + pub task_category: String, + pub description: String, + pub complexity: String, + /// Provenance of THIS summary: `Some("apple-fm-on-device")` when produced by + /// Apple FM, `None` when it came from the heuristic (including per-session + /// fallbacks). Carried per-summary so heuristic results are never recorded + /// as Apple-FM-generated. + pub fm_version: Option, +} + +/// Allowed task categories. Anything else is coerced to `"other"`. +/// Only consumed by the FM validation path (feature-gated). +#[cfg_attr(not(all(target_os = "macos", feature = "apple-fm")), allow(dead_code))] +pub const VALID_CATEGORIES: &[&str] = &[ + "feature", "bugfix", "refactor", "research", "debug", "review", "docs", "config", "other", +]; + +/// Allowed complexity levels. Anything else is coerced to `"moderate"`. +/// Only consumed by the FM validation path (feature-gated). +#[cfg_attr(not(all(target_os = "macos", feature = "apple-fm")), allow(dead_code))] +pub const VALID_COMPLEXITIES: &[&str] = &["trivial", "moderate", "complex"]; + +/// Deterministic, cross-platform fallback classifier. +/// +/// Direct port of the former Python `fallback_classify`, with identical +/// thresholds: +/// - complexity: `total_tokens > 200_000 || duration_minutes > 120` => `complex`; +/// else `total_tokens > 50_000 || duration_minutes > 30` => `moderate`; +/// else `trivial`. +/// - project name: the path component after the last `/` of the workspace, or +/// `"unknown"` when the workspace is empty. +/// - title: `Work on {project_name}`; category: `other`; +/// description: `Session in {project_name} using {models joined ", "}.` +/// (models default to `unknown` when none are recorded). +pub fn heuristic_classify(session: &SessionInput) -> SessionSummary { + let complexity = if session.total_tokens > 200_000 || session.duration_minutes > 120 { + "complex" + } else if session.total_tokens > 50_000 || session.duration_minutes > 30 { + "moderate" + } else { + "trivial" + }; + + let project_name = if session.workspace.is_empty() { + "unknown".to_string() + } else { + session + .workspace + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("unknown") + .to_string() + }; + + let models = if session.models_used.is_empty() { + "unknown".to_string() + } else { + session.models_used.join(", ") + }; + + SessionSummary { + session_id: session.session_id.clone(), + title: format!("Work on {project_name}"), + task_category: "other".to_string(), + description: format!("Session in {project_name} using {models}."), + complexity: complexity.to_string(), + fm_version: None, + } +} + +#[cfg(all(target_os = "macos", feature = "apple-fm"))] +mod imp { + use super::{heuristic_classify, SessionInput, SessionSummary}; + use super::{VALID_CATEGORIES, VALID_COMPLEXITIES}; + use std::ffi::{c_char, c_int, c_void, CStr, CString}; + use std::os::unix::ffi::OsStrExt; + use std::path::PathBuf; + use std::sync::mpsc; + use std::sync::OnceLock; + use std::time::Duration; + + /// Upper bound on a single on-device generation. A short classification + /// completes in seconds; this only guards against a callback that never + /// fires (which would otherwise block the calling thread forever). On + /// timeout the session falls back to the heuristic. + const FM_GENERATION_TIMEOUT: Duration = Duration::from_secs(60); + + /// Upper bound on the first-user-message text appended to the prompt. The + /// on-device model has a small context window, so a large pasted message + /// (stack trace, file dump, multi-KB prompt) is truncated here. Larger than + /// the CLI backend's 200-char cap since the FM prompt carries only one + /// session at a time. + const MAX_FIRST_USER_MESSAGE_CHARS: usize = 1000; + + /// First macOS major version that ships `FoundationModels.framework`. + const FM_MIN_MACOS_MAJOR: u32 = 26; + + /// Verbatim system instructions for the classifier (matches the former + /// Python backend exactly). + const SYSTEM_INSTRUCTIONS: &str = "You are a coding session classifier. Given metadata about an AI coding session, produce a structured summary.\n\nRules:\n- title: 3-8 word description of what was done (imperative mood, e.g. \"Add JWT auth middleware\")\n- task_category: exactly one of: feature, bugfix, refactor, research, debug, review, docs, config, other\n- description: 1-2 sentences explaining what happened in the session\n- complexity: exactly one of: trivial, moderate, complex\n\nBase your classification on:\n- The first user message (primary signal)\n- The workspace name (project context)\n- Token count and duration (complexity signal)\n- Models used (opus = likely complex, haiku = likely trivial)\n\nRespond ONLY with valid JSON matching the schema."; + + // Opaque FoundationModels handles. All are `const void*` in the C ABI. + type FMRef = *const c_void; + + /// Callback signature: `void (*)(int status, FMGeneratedContentRef content, void* userInfo)`. + type StructuredCallback = extern "C" fn(status: c_int, content: FMRef, user_info: *mut c_void); + + // --- dl* / sysctl: libSystem symbols, ALWAYS present, no FM/Swift linkage. + extern "C" { + fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + fn dlerror() -> *const c_char; + fn sysctlbyname( + name: *const c_char, + oldp: *mut c_void, + oldlenp: *mut usize, + newp: *mut c_void, + newlen: usize, + ) -> c_int; + } + const RTLD_NOW: c_int = 2; + const RTLD_LOCAL: c_int = 4; + + // --- Resolved FoundationModels C-ABI entry points (loaded via dlsym). The + // signatures mirror the vendored `foundation-models-c` header exactly. + type FnGetDefault = unsafe extern "C" fn() -> FMRef; + type FnIsAvailable = unsafe extern "C" fn(FMRef, *mut c_int) -> bool; + type FnSessionCreate = unsafe extern "C" fn(FMRef, *const c_char, *mut FMRef, c_int) -> FMRef; + type FnPromptInit = unsafe extern "C" fn() -> FMRef; + type FnPromptAddText = unsafe extern "C" fn(FMRef, *const c_char); + type FnSchemaCreate = unsafe extern "C" fn(*const c_char, *const c_char) -> FMRef; + type FnPropertyCreate = + unsafe extern "C" fn(*const c_char, *const c_char, *const c_char, bool) -> FMRef; + type FnPropertyAddAnyOf = unsafe extern "C" fn(FMRef, *const *const c_char, c_int, bool); + type FnSchemaAddProperty = unsafe extern "C" fn(FMRef, FMRef); + type FnRespondWithSchema = unsafe extern "C" fn( + FMRef, + FMRef, + FMRef, + *const c_char, + *mut c_void, + StructuredCallback, + ) -> FMRef; + type FnContentGetJSON = unsafe extern "C" fn(FMRef) -> *mut c_char; + type FnRelease = unsafe extern "C" fn(FMRef); + type FnFreeString = unsafe extern "C" fn(*mut c_char); + + /// The resolved FoundationModels API plus the (never-closed) dlopen handle. + /// + /// `FMGenerationSchemaCreate` / `...PropertyCreate` return a +1-retained ref + /// (`Unmanaged.passRetained`), so each must be `release`d. The builder's + /// `addProperty` copies the property into a Swift array (it holds its own + /// strong reference), so a property ref may be released immediately after + /// `schema_add_property`. The structured-response callback receives a + /// `content` handle the shim hands over with a +1 retain on EVERY invocation + /// (success AND error/cancel); `content_get_json` only borrows it, so the + /// callback owns that +1 and must `release(content)` exactly once on every + /// path or one generated-content wrapper leaks per generation. + #[allow(dead_code)] + struct Api { + /// Kept alive for the process lifetime; the dylib is never `dlclose`d. + handle: *mut c_void, + get_default: FnGetDefault, + is_available: FnIsAvailable, + session_create: FnSessionCreate, + prompt_init: FnPromptInit, + prompt_add_text: FnPromptAddText, + schema_create: FnSchemaCreate, + property_create: FnPropertyCreate, + property_add_anyof: FnPropertyAddAnyOf, + schema_add_property: FnSchemaAddProperty, + respond_with_schema: FnRespondWithSchema, + content_get_json: FnContentGetJSON, + release: FnRelease, + free_string: FnFreeString, + } + + // `Api` holds only function pointers and an opaque handle; the function + // pointers are immutable after load and safe to call from any thread (the + // background structured callback reads them via `api()`). + unsafe impl Send for Api {} + unsafe impl Sync for Api {} + + impl Api { + /// dlsym every entry point off `handle`. Returns `None` if any symbol is + /// missing (treated as "FM unavailable" -> heuristic). + unsafe fn load(handle: *mut c_void) -> Option { + // dlsym + transmute one symbol. `transmute_copy` because `T` is a + // (pointer-sized) fn-pointer type and plain `transmute` can't prove + // size equality for a generic. + unsafe fn sym(handle: *mut c_void, name: &[u8]) -> Option { + debug_assert_eq!( + name.last(), + Some(&0u8), + "symbol name must be NUL-terminated" + ); + let p = dlsym(handle, name.as_ptr() as *const c_char); + if p.is_null() { + None + } else { + Some(std::mem::transmute_copy::<*mut c_void, T>(&p)) + } + } + + Some(Api { + handle, + get_default: sym(handle, b"FMSystemLanguageModelGetDefault\0")?, + is_available: sym(handle, b"FMSystemLanguageModelIsAvailable\0")?, + session_create: sym( + handle, + b"FMLanguageModelSessionCreateFromSystemLanguageModel\0", + )?, + prompt_init: sym(handle, b"FMComposedPromptInitialize\0")?, + prompt_add_text: sym(handle, b"FMComposedPromptAddText\0")?, + schema_create: sym(handle, b"FMGenerationSchemaCreate\0")?, + property_create: sym(handle, b"FMGenerationSchemaPropertyCreate\0")?, + property_add_anyof: sym(handle, b"FMGenerationSchemaPropertyAddAnyOfGuide\0")?, + schema_add_property: sym(handle, b"FMGenerationSchemaAddProperty\0")?, + respond_with_schema: sym(handle, b"FMLanguageModelSessionRespondWithSchema\0")?, + content_get_json: sym(handle, b"FMGeneratedContentGetJSONString\0")?, + release: sym(handle, b"FMRelease\0")?, + free_string: sym(handle, b"FMFreeString\0")?, + }) + } + } + + /// Read the macOS major version via `sysctl kern.osproductversion` + /// (e.g. `"26.1"` -> `26`). `None` if it can't be determined. + fn macos_major() -> Option { + let name = b"kern.osproductversion\0"; + let mut size: usize = 0; + // Probe the buffer size. + let rc = unsafe { + sysctlbyname( + name.as_ptr() as *const c_char, + std::ptr::null_mut(), + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + if rc != 0 || size == 0 { + return None; + } + let mut buf = vec![0u8; size]; + let rc = unsafe { + sysctlbyname( + name.as_ptr() as *const c_char, + buf.as_mut_ptr() as *mut c_void, + &mut size, + std::ptr::null_mut(), + 0, + ) + }; + if rc != 0 { + return None; + } + let s = CStr::from_bytes_until_nul(&buf).ok()?.to_str().ok()?; + s.split('.').next()?.parse::().ok() + } + + /// Candidate `libFoundationModels.dylib` locations, in priority order: + /// 1. next to the running binary (npm package layout, `cargo run`), + /// 2. the absolute OUT_DIR copy baked in at build time (`cargo test`, where + /// the test harness binary lives in `target//deps`). + fn candidate_paths() -> Vec { + let mut v = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + v.push(dir.join("libFoundationModels.dylib")); + } + } + if let Some(p) = option_env!("TOKSCALE_FM_DYLIB") { + v.push(PathBuf::from(p)); + } + v + } + + /// Load (once) the FoundationModels dylib and resolve its entry points. + /// + /// Returns `None` — caller falls back to the heuristic — when: + /// - the OS is older than macOS 26 (no `FoundationModels.framework`), or + /// - the dylib isn't found / can't be loaded (e.g. dependencies absent), or + /// - an expected symbol is missing. + fn load_api() -> Option { + // Set `TOKSCALE_FM_DEBUG=1` to trace why apple-fm did/didn't engage + // (OS gate, which dylib path loaded, dlopen errors, symbol resolution). + let debug = std::env::var_os("TOKSCALE_FM_DEBUG").is_some(); + + // Fast OS gate. The dylib's transitive deps (FoundationModels.framework + // + macOS-26 Swift runtime) only exist on macOS 26+, so on older systems + // the dlopen below would fail anyway; this documents the contract and + // avoids a doomed load attempt. + let major = macos_major(); + if debug { + eprintln!(" apple-fm[debug]: macos_major={major:?}"); + } + if let Some(major) = major { + if major < FM_MIN_MACOS_MAJOR { + if debug { + eprintln!( + " apple-fm[debug]: OS gate {major} < {FM_MIN_MACOS_MAJOR} -> heuristic" + ); + } + return None; + } + } + + for path in candidate_paths() { + let exists = path.exists(); + let c = match CString::new(path.as_os_str().as_bytes()) { + Ok(c) => c, + Err(_) => continue, + }; + let handle = unsafe { dlopen(c.as_ptr(), RTLD_NOW | RTLD_LOCAL) }; + if handle.is_null() { + if debug { + let err = unsafe { dlerror() }; + let msg = if err.is_null() { + "(no dlerror)".to_string() + } else { + unsafe { CStr::from_ptr(err) } + .to_string_lossy() + .into_owned() + }; + eprintln!( + " apple-fm[debug]: dlopen failed (exists={exists}) {} :: {msg}", + path.display() + ); + } + continue; + } + if debug { + eprintln!(" apple-fm[debug]: dlopen ok {}", path.display()); + } + // Leave the handle open for the process lifetime on success; on a + // (very unexpected) missing symbol, move on to the next candidate. + if let Some(api) = unsafe { Api::load(handle) } { + return Some(api); + } + if debug { + eprintln!( + " apple-fm[debug]: symbol resolution failed for {}", + path.display() + ); + } + } + if debug { + eprintln!(" apple-fm[debug]: no usable FoundationModels dylib -> heuristic"); + } + None + } + + /// Process-wide resolved API, or `None` if FM is unavailable on this box. + fn api() -> Option<&'static Api> { + static API: OnceLock> = OnceLock::new(); + API.get_or_init(load_api).as_ref() + } + + /// What the background callback ships back to the blocked calling thread: + /// `Ok(json)` on success, `Err(status)` on failure. + type CallbackResult = Result; + + /// Heap-allocated channel sender handed to the C callback as `userInfo`. + struct CallbackBox { + tx: mpsc::Sender, + } + + /// The structured-response callback. Invoked on a BACKGROUND thread by the + /// Swift bridge. Copies the JSON out of the generated content and signals + /// the waiting thread via the channel. + extern "C" fn structured_callback(status: c_int, content: FMRef, user_info: *mut c_void) { + // Reconstruct the boxed sender. We own it now and drop it at end of scope. + if user_info.is_null() { + return; + } + let cb: Box = unsafe { Box::from_raw(user_info as *mut CallbackBox) }; + + // The callback can only fire after a successful `respond_with_schema` + // call, which required `api()` to be `Some`; so this lookup never fails + // in practice, but we degrade to `Err(status)` if it somehow does. + let api = api(); + + let result: CallbackResult = match api { + Some(api) if status == 0 && !content.is_null() => { + // SAFETY: content is non-null; the returned string is malloc'd + // and must be freed via `free_string`. + let json_ptr = unsafe { (api.content_get_json)(content) }; + if json_ptr.is_null() { + Err(status) + } else { + let json = unsafe { CStr::from_ptr(json_ptr) } + .to_string_lossy() + .into_owned(); + unsafe { (api.free_string)(json_ptr) }; + Ok(json) + } + } + _ => Err(status), + }; + + // The shim hands us a +1-retained `content` on EVERY callback path + // (success and error/cancel) and `content_get_json` only borrows it, so + // we own that retain and must release it here exactly once or one + // generated-content wrapper leaks per generation. + if !content.is_null() { + if let Some(api) = api { + unsafe { (api.release)(content) }; + } + } + + // Best-effort send; if the receiver is gone there is nothing to do. + let _ = cb.tx.send(result); + } + + /// Build the per-session prompt text (matches the former Python `build_prompt`). + fn build_prompt(input: &SessionInput) -> String { + let workspace = if input.workspace.is_empty() { + "unknown" + } else { + input.workspace.as_str() + }; + let client = if input.client.is_empty() { + "unknown" + } else { + input.client.as_str() + }; + let models = input.models_used.join(", "); + + let mut s = format!( + "Workspace: {workspace}\nClient: {client}\nModels: {models}\nTotal tokens: {}\nDuration: {} minutes\nMessages: {}", + input.total_tokens, input.duration_minutes, input.message_count + ); + + match &input.first_user_message { + Some(msg) if !msg.is_empty() => { + s.push_str("\n\nFirst user message:\n"); + // Cap the (possibly multi-KB) pasted message: on-device FM has a + // small context window, so an oversized prompt risks truncation, + // refusal, or latency. `chars().take` is char-boundary-safe. + let capped: String = msg.chars().take(MAX_FIRST_USER_MESSAGE_CHARS).collect(); + s.push_str(&capped); + } + _ => { + s.push_str("\n\nNo user message content available."); + } + } + s + } + + /// Coerce parsed category/complexity to the allowed sets. + fn normalize_category(raw: &str) -> String { + if VALID_CATEGORIES.contains(&raw) { + raw.to_string() + } else { + "other".to_string() + } + } + fn normalize_complexity(raw: &str) -> String { + if VALID_COMPLEXITIES.contains(&raw) { + raw.to_string() + } else { + "moderate".to_string() + } + } + + /// Parse the FM-returned JSON into a [`SessionSummary`], coercing invalid + /// enum values. Returns `None` if the JSON is unusable. + fn parse_summary(session_id: &str, json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).ok()?; + let title = value + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("Untitled session") + .to_string(); + let task_category = normalize_category( + value + .get("task_category") + .and_then(|v| v.as_str()) + .unwrap_or("other"), + ); + let description = value + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let complexity = normalize_complexity( + value + .get("complexity") + .and_then(|v| v.as_str()) + .unwrap_or("moderate"), + ); + Some(SessionSummary { + session_id: session_id.to_string(), + title, + task_category, + description, + complexity, + fm_version: Some("apple-fm-on-device".to_string()), + }) + } + + /// Build the programmatic `SessionSummary` GenerationSchema once, enforcing + /// the category/complexity enums on-device via `anyOf` guides. Returns a + /// +1-retained schema ref the caller must `release` after the per-session + /// loop, or `None` if any CString conversion fails. + /// + /// typeName is the lowercase `"string"` literal: the shim matches it with + /// `case "string":` (FoundationModelsCBindings.swift) to produce a + /// `String`-typed property. Any other casing (e.g. "String") falls through + /// to the "reference to another schema" branch and fails to build. The + /// `anyOf` guide is added UNWRAPPED (`wrapped=false`): for a scalar String + /// the shim's `resolveStringGuides` handles `.anyOf` directly, whereas a + /// wrapped (`.element`) guide is only valid for array types and would throw + /// `unsupportedGuide`. + fn build_schema(api: &Api) -> Option { + // typeName literal the shim maps to a Swift `String` property. + let type_string = CString::new("string").ok()?; + let schema_name = CString::new("SessionSummary").ok()?; + let schema = unsafe { (api.schema_create)(schema_name.as_ptr(), std::ptr::null()) }; + if schema.is_null() { + return None; + } + + // Helper: create a property, optionally constrain it to an enum set via + // an unwrapped anyOf guide, add it to the schema, then release the + // property's +1 retain (the builder copied it into its own array). + let add_prop = |name: &str, choices: Option<&[&str]>| -> Option<()> { + let name_c = CString::new(name).ok()?; + let prop = unsafe { + (api.property_create)( + name_c.as_ptr(), + std::ptr::null(), + type_string.as_ptr(), + false, + ) + }; + if prop.is_null() { + return None; + } + if let Some(choices) = choices { + // Keep the CStrings alive until after the FFI call. + let owned: Vec = choices + .iter() + .map(|c| CString::new(*c)) + .collect::>() + .ok()?; + let ptrs: Vec<*const c_char> = owned.iter().map(|c| c.as_ptr()).collect(); + unsafe { + (api.property_add_anyof)(prop, ptrs.as_ptr(), ptrs.len() as c_int, false); + } + } + unsafe { + (api.schema_add_property)(schema, prop); + // Builder holds its own strong ref; release our creation +1. + (api.release)(prop); + } + Some(()) + }; + + // On any property failure, release the schema and bail. + let built = (|| { + add_prop("title", None)?; + add_prop("description", None)?; + add_prop("task_category", Some(VALID_CATEGORIES))?; + add_prop("complexity", Some(VALID_COMPLEXITIES))?; + Some(()) + })(); + if built.is_none() { + unsafe { (api.release)(schema) }; + return None; + } + + Some(schema) + } + + /// Run a single structured generation for `input`, blocking the calling + /// thread until the background callback fires. Returns the parsed summary, + /// or `None` on any error (caller falls back to the heuristic). + /// + /// `schema` is the prebuilt, shared GenerationSchema ref (see + /// [`build_schema`]); the shim borrows it unretained per call, so it stays + /// owned by the caller across the loop. + /// + /// A FRESH `LanguageModelSession` is created per input: the session is + /// stateful (it accumulates a transcript), so reusing one across sessions + /// would condition later summaries on earlier prompts/responses — and a + /// timed-out generation could leave a shared session busy. This mirrors the + /// former Python backend, which built a new session inside its loop. + fn respond_one( + api: &Api, + model: FMRef, + instructions: &CStr, + schema: FMRef, + input: &SessionInput, + ) -> Option { + let session_ref = + unsafe { (api.session_create)(model, instructions.as_ptr(), std::ptr::null_mut(), 0) }; + if session_ref.is_null() { + return None; + } + + // Build the prompt CString BEFORE allocating the composed-prompt handle, + // so an unexpected NUL byte cannot leak an allocated FM handle. + let prompt_text = match CString::new(build_prompt(input)) { + Ok(c) => c, + Err(_) => { + unsafe { (api.release)(session_ref) }; + return None; + } + }; + let prompt_ref = unsafe { (api.prompt_init)() }; + if prompt_ref.is_null() { + unsafe { (api.release)(session_ref) }; + return None; + } + unsafe { (api.prompt_add_text)(prompt_ref, prompt_text.as_ptr()) }; + + let (tx, rx) = mpsc::channel::(); + let cb_box = Box::new(CallbackBox { tx }); + let user_info = Box::into_raw(cb_box) as *mut c_void; + + let task_ref = unsafe { + (api.respond_with_schema)( + session_ref, + prompt_ref, + schema, + std::ptr::null(), + user_info, + structured_callback, + ) + }; + + // Block on the background callback (bounded). The callback reclaims + // `user_info` (the boxed sender). On timeout we deliberately do NOT + // reclaim it here: the detached Swift task may still fire the callback + // later, so freeing the box now would risk a use-after-free. The box + // (one channel sender) is leaked instead — a bounded, rare cost paid + // only when a generation exceeds the 60s timeout. + let received = rx.recv_timeout(FM_GENERATION_TIMEOUT); + + // Release the task handle, composed prompt, and this input's session. + if !task_ref.is_null() { + unsafe { (api.release)(task_ref) }; + } + unsafe { (api.release)(prompt_ref) }; + unsafe { (api.release)(session_ref) }; + + match received { + Ok(Ok(json)) => parse_summary(&input.session_id, &json), + // Surface the failure mode so silent degradation to the heuristic is + // diagnosable (the FM-vs-heuristic breakdown reports the count; this + // names the cause). Non-success status codes flow through here. + Ok(Err(status)) => { + eprintln!( + " apple-fm: generation failed for {} (status {}); using heuristic", + input.session_id, status + ); + None + } + Err(_) => { + eprintln!( + " apple-fm: generation timed out for {} after {}s; using heuristic", + input.session_id, + FM_GENERATION_TIMEOUT.as_secs() + ); + None + } + } + } + + /// Real FFI implementation. See module docs and [`super::summarize`]. + pub fn summarize(sessions: &[SessionInput]) -> Option> { + if sessions.is_empty() { + return Some(Vec::new()); + } + + // 0) Resolve the dylib + entry points (OS gate + dlopen happen here). + // `None` => old macOS / dylib missing => caller uses the heuristic. + let api = api()?; + + // 1) Default model + availability gate. NEVER generate if unavailable. + let model = unsafe { (api.get_default)() }; + if model.is_null() { + return None; + } + let available = unsafe { (api.is_available)(model, std::ptr::null_mut()) }; + if !available { + unsafe { (api.release)(model) }; + return None; + } + + // 2) Prepare the shared instructions + output schema once. respond_one + // creates a FRESH session per input from these (see its docs). + let instructions = match CString::new(SYSTEM_INSTRUCTIONS) { + Ok(c) => c, + Err(_) => { + unsafe { (api.release)(model) }; + return None; + } + }; + // Build the programmatic output schema ONCE and share it across the + // per-session loop (the shim borrows it unretained per call). Released + // after the loop. If schema construction fails, fall back entirely. + let schema = match build_schema(api) { + Some(s) => s, + None => { + unsafe { (api.release)(model) }; + return None; + } + }; + + // 3) One structured generation per session; per-session errors fall + // back to the heuristic for that single session. + let mut results = Vec::with_capacity(sessions.len()); + for input in sessions { + match respond_one(api, model, instructions.as_c_str(), schema, input) { + Some(summary) => results.push(summary), + None => results.push(heuristic_classify(input)), + } + } + + unsafe { + (api.release)(schema); + (api.release)(model); + } + + Some(results) + } +} + +/// Summarize sessions using Apple's on-device FoundationModels. +/// +/// Returns: +/// - `Some(results)` when the model is available and generation ran (per-session +/// failures are individually backfilled with [`heuristic_classify`]). +/// - `None` when Apple Intelligence is unavailable, the feature is off, or the +/// target is not macOS. The caller must then apply the heuristic to all +/// sessions. This function never errors. +#[cfg(all(target_os = "macos", feature = "apple-fm"))] +pub fn summarize(sessions: &[SessionInput]) -> Option> { + imp::summarize(sessions) +} + +/// Stub used when the `apple-fm` feature is off or the target is not macOS. +/// Always returns `None` so the caller falls back to the heuristic. +#[cfg(not(all(target_os = "macos", feature = "apple-fm")))] +pub fn summarize(_sessions: &[SessionInput]) -> Option> { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input( + total_tokens: i64, + duration_minutes: i64, + workspace: &str, + models: &[&str], + ) -> SessionInput { + SessionInput { + session_id: "ses_test".to_string(), + client: "opencode".to_string(), + workspace: workspace.to_string(), + first_user_message: None, + models_used: models.iter().map(|s| s.to_string()).collect(), + total_tokens, + duration_minutes, + message_count: 1, + } + } + + #[test] + fn complexity_complex_by_tokens() { + let s = heuristic_classify(&input(200_001, 0, "/x/proj", &["opus"])); + assert_eq!(s.complexity, "complex"); + } + + #[test] + fn complexity_complex_by_duration() { + let s = heuristic_classify(&input(0, 121, "/x/proj", &["opus"])); + assert_eq!(s.complexity, "complex"); + } + + #[test] + fn complexity_moderate_by_tokens() { + let s = heuristic_classify(&input(50_001, 0, "/x/proj", &["sonnet"])); + assert_eq!(s.complexity, "moderate"); + } + + #[test] + fn complexity_moderate_by_duration() { + let s = heuristic_classify(&input(0, 31, "/x/proj", &["sonnet"])); + assert_eq!(s.complexity, "moderate"); + } + + #[test] + fn complexity_trivial() { + let s = heuristic_classify(&input(50_000, 30, "/x/proj", &["haiku"])); + assert_eq!(s.complexity, "trivial"); + } + + #[test] + fn complexity_boundaries_are_exclusive() { + // Exactly at the thresholds => the lower tier (strictly-greater compares). + assert_eq!( + heuristic_classify(&input(200_000, 120, "/x/p", &[])).complexity, + "moderate" + ); + assert_eq!( + heuristic_classify(&input(50_000, 30, "/x/p", &[])).complexity, + "trivial" + ); + } + + #[test] + fn project_name_and_title_from_workspace() { + let s = heuristic_classify(&input(0, 0, "/Users/x/tokscale", &["claude-opus-4"])); + assert_eq!(s.title, "Work on tokscale"); + assert_eq!(s.task_category, "other"); + assert_eq!(s.description, "Session in tokscale using claude-opus-4."); + } + + #[test] + fn project_name_unknown_when_empty_workspace() { + let s = heuristic_classify(&input(0, 0, "", &[])); + assert_eq!(s.title, "Work on unknown"); + assert_eq!(s.description, "Session in unknown using unknown."); + } + + #[test] + fn description_joins_multiple_models() { + let s = heuristic_classify(&input(0, 0, "/a/b/myrepo", &["opus", "haiku"])); + assert_eq!(s.title, "Work on myrepo"); + assert_eq!(s.description, "Session in myrepo using opus, haiku."); + } + + #[test] + fn stub_or_gate_returns_some_or_none_without_panicking() { + // On non-macOS / feature-off this returns None; on macOS+feature it may + // return None (unavailable) or Some. Either way it must not panic. + let _ = summarize(&[input(1, 1, "/x/p", &["m"])]); + } + + /// Live end-to-end check against the real on-device model. Kept `#[ignore]`d + /// so it never runs in CI (it requires Apple Intelligence enabled + the + /// on-device model READY). Run manually with: + /// cargo test -p tokscale-cli --features apple-fm -- --ignored live_summarize_smoke + /// Documents the live path; use THIS, not the segfaulting fm-c-example + /// streaming binary, as the on-device smoke test on macOS 26.x. + #[cfg(all(target_os = "macos", feature = "apple-fm"))] + #[test] + #[ignore] + fn live_summarize_smoke() { + let sessions = vec![ + SessionInput { + session_id: "ses_live_1".to_string(), + client: "claude".to_string(), + workspace: "/Users/x/payments-api".to_string(), + first_user_message: Some( + "Add JWT auth middleware to the payments API and write tests.".to_string(), + ), + models_used: vec!["claude-opus-4".to_string()], + total_tokens: 120_000, + duration_minutes: 45, + message_count: 12, + }, + SessionInput { + session_id: "ses_live_2".to_string(), + client: "claude".to_string(), + workspace: "/Users/x/dashboard".to_string(), + first_user_message: Some( + "The settings page crashes with a null pointer when the avatar URL is empty; \ + find and fix the bug." + .to_string(), + ), + models_used: vec!["claude-haiku-4".to_string()], + total_tokens: 8_000, + duration_minutes: 10, + message_count: 4, + }, + ]; + + let out = summarize(&sessions).expect("FM should be available on this box"); + assert_eq!(out.len(), 2); + for s in &out { + eprintln!( + "live[{}]: title={:?} category={:?} complexity={:?} fm_version={:?}\n desc={:?}", + s.session_id, s.title, s.task_category, s.complexity, s.fm_version, s.description + ); + } + // After a working schema/generation, every summary must be FM-produced, + // not the heuristic backfill. + for s in &out { + assert_eq!( + s.fm_version.as_deref(), + Some("apple-fm-on-device"), + "expected FM-generated provenance, got heuristic fallback for {}", + s.session_id + ); + assert!(VALID_CATEGORIES.contains(&s.task_category.as_str())); + assert!(VALID_COMPLEXITIES.contains(&s.complexity.as_str())); + } + } +} diff --git a/crates/tokscale-cli/src/commands/autosubmit.rs b/crates/tokscale-cli/src/commands/autosubmit.rs new file mode 100644 index 000000000..aaed944b7 --- /dev/null +++ b/crates/tokscale-cli/src/commands/autosubmit.rs @@ -0,0 +1,1024 @@ +use crate::tui::settings::{ + AutosubmitSettings, DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES, MAX_AUTOSUBMIT_INTERVAL_MINUTES, + MIN_AUTOSUBMIT_INTERVAL_MINUTES, +}; +use crate::{ClientFlags, DateRangeFlags}; +use anyhow::{bail, Context, Result}; +use clap::{Args, Subcommand, ValueEnum}; +use fs2::FileExt; +use serde::Serialize; +use std::fs::{self, OpenOptions}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const JOB_ID: &str = "ai.tokscale.autosubmit"; +const CRON_MARKER_BEGIN: &str = "# BEGIN TOKSCALE AUTOSUBMIT"; +const CRON_MARKER_END: &str = "# END TOKSCALE AUTOSUBMIT"; +const SKIP_SCHEDULER_ENV: &str = "TOKSCALE_AUTOSUBMIT_SKIP_SCHEDULER"; + +#[derive(Subcommand)] +pub enum AutosubmitSubcommand { + #[command(about = "Enable periodic submit using the OS scheduler")] + Enable(AutosubmitEnableArgs), + #[command(about = "Show autosubmit status")] + Status { + #[arg(long, help = "Output as JSON")] + json: bool, + }, + #[command(about = "Disable autosubmit and remove its scheduler entry")] + Disable, + #[command(about = "Run autosubmit once if it is due")] + Run { + #[arg(long, help = "Run even when the configured interval has not elapsed")] + force: bool, + }, +} + +#[derive(Args)] +pub struct AutosubmitEnableArgs { + #[arg( + long, + value_name = "DURATION", + default_value = "24h", + help = "Submit interval, e.g. 30m, 2h, or 1d" + )] + interval: String, + #[command(flatten)] + clients: ClientFlags, + #[command(flatten)] + date: DateRangeFlags, + #[arg(long, value_enum, help = "Override the detected scheduler backend")] + scheduler: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize, ValueEnum)] +#[serde(rename_all = "kebab-case")] +pub enum SchedulerKind { + Launchd, + Systemd, + Cron, + WindowsTaskScheduler, +} + +impl SchedulerKind { + fn as_str(self) -> &'static str { + match self { + Self::Launchd => "launchd", + Self::Systemd => "systemd", + Self::Cron => "cron", + Self::WindowsTaskScheduler => "windows-task-scheduler", + } + } + + fn from_str(value: &str) -> Option { + match value { + "launchd" => Some(Self::Launchd), + "systemd" => Some(Self::Systemd), + "cron" => Some(Self::Cron), + "windows-task-scheduler" => Some(Self::WindowsTaskScheduler), + _ => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AutosubmitRunDecision { + Disabled, + NotDue { next_run_at_ms: i64 }, + Due, +} + +pub struct AutosubmitRunLock { + _file: std::fs::File, +} + +#[derive(Debug, Clone)] +struct SchedulerSpec { + files: Vec<(PathBuf, String)>, + install_commands: Vec<(String, Vec)>, + uninstall_commands: Vec<(String, Vec)>, + cron_block: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct StatusOutput { + enabled: bool, + interval_minutes: u64, + scheduler: Option, + clients: Vec, + since: Option, + until: Option, + year: Option, + today: bool, + yesterday: bool, + week: bool, + month: bool, + last_run_at_ms: Option, + last_error: Option, +} + +pub fn enable(args: AutosubmitEnableArgs) -> Result<()> { + let interval_minutes = parse_interval_minutes(&args.interval)?; + let scheduler = args.scheduler.unwrap_or_else(default_scheduler_kind); + let exe = std::env::current_exe().context("Could not resolve current tokscale executable")?; + validate_scheduler_executable(&exe)?; + + let mut settings = crate::tui::settings::Settings::load(); + settings.autosubmit = AutosubmitSettings { + enabled: true, + interval_minutes, + clients: clients_for_settings(args.clients), + since: args.date.since, + until: args.date.until, + year: args.date.year, + today: args.date.today, + yesterday: args.date.yesterday, + week: args.date.week, + month: args.date.month, + scheduler: Some(scheduler.as_str().to_string()), + last_run_at_ms: settings.autosubmit.last_run_at_ms, + last_error: None, + }; + + if !skip_scheduler_install() { + install_scheduler(scheduler, &exe, &settings.autosubmit)?; + } + settings.save()?; + + println!( + "Autosubmit enabled: every {} minutes via {}.", + interval_minutes, + scheduler.as_str() + ); + Ok(()) +} + +pub fn status(json: bool) -> Result<()> { + let autosubmit = crate::tui::settings::Settings::load().autosubmit; + if json { + println!( + "{}", + serde_json::to_string_pretty(&status_output(&autosubmit))? + ); + return Ok(()); + } + + if autosubmit.enabled { + println!("Autosubmit is enabled."); + println!(" Interval: {} minutes", autosubmit.interval_minutes); + println!( + " Scheduler: {}", + autosubmit.scheduler.as_deref().unwrap_or("unknown") + ); + if !autosubmit.clients.is_empty() { + println!(" Clients: {}", autosubmit.clients.join(", ")); + } else { + println!(" Clients: default submit clients"); + } + } else { + println!("Autosubmit is disabled."); + } + if let Some(last_run_at_ms) = autosubmit.last_run_at_ms { + println!(" Last run: {}", format_timestamp_ms(last_run_at_ms)); + } + if let Some(error) = autosubmit.last_error { + println!(" Last error: {error}"); + } + Ok(()) +} + +pub fn disable() -> Result<()> { + let mut settings = crate::tui::settings::Settings::load(); + let scheduler = settings + .autosubmit + .scheduler + .as_deref() + .and_then(SchedulerKind::from_str) + .unwrap_or_else(default_scheduler_kind); + + if settings.autosubmit.enabled && !skip_scheduler_install() { + uninstall_scheduler(scheduler)?; + } + + settings.autosubmit.enabled = false; + settings.autosubmit.last_error = None; + settings.save()?; + println!("Autosubmit disabled."); + Ok(()) +} + +pub fn load_run_config( + force: bool, + now_ms: i64, +) -> Result<(AutosubmitSettings, AutosubmitRunDecision)> { + let settings = crate::tui::settings::Settings::load().autosubmit; + let decision = run_decision(&settings, now_ms, force); + Ok((settings, decision)) +} + +pub fn record_run_success(now_ms: i64) -> Result<()> { + let mut settings = crate::tui::settings::Settings::load(); + settings.autosubmit.last_run_at_ms = Some(now_ms); + settings.autosubmit.last_error = None; + settings.save() +} + +pub fn record_run_error(error: &str) -> Result<()> { + let mut settings = crate::tui::settings::Settings::load(); + settings.autosubmit.last_error = Some(error.to_string()); + settings.save() +} + +pub fn submit_filters( + settings: &AutosubmitSettings, +) -> ( + Option>, + Option, + Option, + Option, +) { + let clients = if settings.clients.is_empty() { + Some(default_submit_clients()) + } else { + Some(settings.clients.clone()) + }; + let date = DateRangeFlags { + today: settings.today, + yesterday: settings.yesterday, + week: settings.week, + month: settings.month, + since: settings.since.clone(), + until: settings.until.clone(), + year: settings.year.clone(), + }; + let (since, until) = build_date_filter_for_date(&date, chrono::Local::now().date_naive()); + let year = if date.today || date.yesterday || date.week || date.month { + None + } else { + date.year + }; + (clients, since, until, year) +} + +pub fn try_acquire_run_lock() -> Result> { + let path = autosubmit_lock_path()?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .with_context(|| format!("Could not open autosubmit lock at {}", path.display()))?; + match file.try_lock_exclusive() { + Ok(()) => Ok(Some(AutosubmitRunLock { _file: file })), + Err(err) if err.kind() == ErrorKind::WouldBlock => Ok(None), + Err(err) => Err(err) + .with_context(|| format!("Could not lock autosubmit state at {}", path.display())), + } +} + +fn status_output(settings: &AutosubmitSettings) -> StatusOutput { + StatusOutput { + enabled: settings.enabled, + interval_minutes: settings.interval_minutes, + scheduler: settings.scheduler.clone(), + clients: settings.clients.clone(), + since: settings.since.clone(), + until: settings.until.clone(), + year: settings.year.clone(), + today: settings.today, + yesterday: settings.yesterday, + week: settings.week, + month: settings.month, + last_run_at_ms: settings.last_run_at_ms, + last_error: settings.last_error.clone(), + } +} + +pub fn run_decision( + settings: &AutosubmitSettings, + now_ms: i64, + force: bool, +) -> AutosubmitRunDecision { + if !settings.enabled { + return AutosubmitRunDecision::Disabled; + } + if force { + return AutosubmitRunDecision::Due; + } + let interval_ms = (settings.interval_minutes as i64).saturating_mul(60_000); + match settings.last_run_at_ms { + Some(last) if now_ms < last.saturating_add(interval_ms) => AutosubmitRunDecision::NotDue { + next_run_at_ms: last.saturating_add(interval_ms), + }, + _ => AutosubmitRunDecision::Due, + } +} + +pub fn parse_interval_minutes(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + bail!("Interval cannot be empty"); + } + let split = trimmed + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(trimmed.len()); + let (number, unit) = trimmed.split_at(split); + let amount: u64 = number + .parse() + .with_context(|| format!("Invalid interval: {input}"))?; + let multiplier = match unit.trim().to_ascii_lowercase().as_str() { + "" | "m" | "min" | "mins" | "minute" | "minutes" => 1, + "h" | "hr" | "hrs" | "hour" | "hours" => 60, + "d" | "day" | "days" => 24 * 60, + _ => bail!("Unsupported interval unit: {unit}"), + }; + let minutes = amount + .checked_mul(multiplier) + .ok_or_else(|| anyhow::anyhow!("Interval is too large"))?; + if !(MIN_AUTOSUBMIT_INTERVAL_MINUTES..=MAX_AUTOSUBMIT_INTERVAL_MINUTES).contains(&minutes) { + bail!( + "Interval must be between {} and {} minutes", + MIN_AUTOSUBMIT_INTERVAL_MINUTES, + MAX_AUTOSUBMIT_INTERVAL_MINUTES + ); + } + Ok(minutes) +} + +fn clients_for_settings(flags: ClientFlags) -> Vec { + if flags.clients.is_empty() { + return Vec::new(); + } + let mut seen = std::collections::HashSet::new(); + flags + .clients + .into_iter() + .map(|client| client.as_filter_str().to_string()) + .filter(|client| seen.insert(client.clone())) + .collect() +} + +fn default_submit_clients() -> Vec { + let mut clients: Vec = tokscale_core::ClientId::iter() + .filter(|client| client.submit_default()) + .map(|client| client.as_str().to_string()) + .collect(); + clients.push("synthetic".to_string()); + clients +} + +fn skip_scheduler_install() -> bool { + std::env::var(SKIP_SCHEDULER_ENV) + .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false) +} + +fn default_scheduler_kind() -> SchedulerKind { + if cfg!(target_os = "macos") { + SchedulerKind::Launchd + } else if cfg!(target_os = "windows") { + SchedulerKind::WindowsTaskScheduler + } else if Command::new("systemctl") + .args(["--user", "--version"]) + .status() + .map(|status| status.success()) + .unwrap_or(false) + { + SchedulerKind::Systemd + } else { + SchedulerKind::Cron + } +} + +fn install_scheduler( + scheduler: SchedulerKind, + exe: &Path, + settings: &AutosubmitSettings, +) -> Result<()> { + let spec = render_scheduler_spec(scheduler, exe, settings)?; + for (path, content) in spec.files { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, content)?; + } + for (program, args) in spec.install_commands { + run_status_command(&program, &args)?; + } + if let Some(block) = spec.cron_block { + install_cron_block(&block)?; + } + Ok(()) +} + +fn uninstall_scheduler(scheduler: SchedulerKind) -> Result<()> { + let dummy = AutosubmitSettings { + interval_minutes: DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES, + ..AutosubmitSettings::default() + }; + let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("tokscale")); + let spec = render_scheduler_spec(scheduler, &exe, &dummy)?; + for (program, args) in spec.uninstall_commands { + let _ = Command::new(&program).args(&args).status(); + } + if scheduler == SchedulerKind::Cron { + let _ = uninstall_cron_block(); + } + for (path, _) in spec.files { + let _ = fs::remove_file(path); + } + Ok(()) +} + +fn render_scheduler_spec( + scheduler: SchedulerKind, + exe: &Path, + settings: &AutosubmitSettings, +) -> Result { + validate_scheduler_executable(exe)?; + match scheduler { + SchedulerKind::Launchd => render_launchd_spec(exe, settings), + SchedulerKind::Systemd => render_systemd_spec(exe, settings), + SchedulerKind::Cron => render_cron_spec(exe, settings), + SchedulerKind::WindowsTaskScheduler => render_windows_task_spec(exe, settings), + } +} + +fn render_launchd_spec(exe: &Path, settings: &AutosubmitSettings) -> Result { + let home = dirs::home_dir().context("Could not determine home directory")?; + let plist_path = home + .join("Library") + .join("LaunchAgents") + .join(format!("{JOB_ID}.plist")); + let log_path = autosubmit_log_path()?; + let interval_seconds = settings.interval_minutes.saturating_mul(60).max(60); + let content = format!( + r#" + + + + Label{job} + ProgramArguments + + {exe} + autosubmit + run + + RunAtLoad + StartInterval{interval} + StandardOutPath{log} + StandardErrorPath{log} + + +"#, + job = xml_escape(JOB_ID), + exe = xml_escape(&exe.to_string_lossy()), + interval = interval_seconds, + log = xml_escape(&log_path.to_string_lossy()) + ); + Ok(SchedulerSpec { + files: vec![(plist_path.clone(), content)], + cron_block: None, + install_commands: vec![( + "launchctl".to_string(), + vec![ + "load".to_string(), + plist_path.to_string_lossy().into_owned(), + ], + )], + uninstall_commands: vec![( + "launchctl".to_string(), + vec![ + "unload".to_string(), + plist_path.to_string_lossy().into_owned(), + ], + )], + }) +} + +fn render_systemd_spec(exe: &Path, settings: &AutosubmitSettings) -> Result { + let user_dir = systemd_user_dir()?; + let service_path = user_dir.join("tokscale-autosubmit.service"); + let timer_path = user_dir.join("tokscale-autosubmit.timer"); + let log_path = autosubmit_log_path()?; + let service = format!( + "[Unit]\nDescription=Tokscale autosubmit\n\n[Service]\nType=oneshot\nExecStart={} autosubmit run\nStandardOutput=append:{}\nStandardError=append:{}\n", + systemd_escape_path(exe), + systemd_escape_path(&log_path), + systemd_escape_path(&log_path) + ); + let timer = format!( + "[Unit]\nDescription=Run Tokscale autosubmit periodically\n\n[Timer]\nOnBootSec=5m\nOnUnitActiveSec={}min\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n", + settings.interval_minutes + ); + Ok(SchedulerSpec { + files: vec![(service_path, service), (timer_path, timer)], + cron_block: None, + install_commands: vec![ + ( + "systemctl".to_string(), + vec!["--user".to_string(), "daemon-reload".to_string()], + ), + ( + "systemctl".to_string(), + vec![ + "--user".to_string(), + "enable".to_string(), + "--now".to_string(), + "tokscale-autosubmit.timer".to_string(), + ], + ), + ], + uninstall_commands: vec![ + ( + "systemctl".to_string(), + vec![ + "--user".to_string(), + "disable".to_string(), + "--now".to_string(), + "tokscale-autosubmit.timer".to_string(), + ], + ), + ( + "systemctl".to_string(), + vec!["--user".to_string(), "daemon-reload".to_string()], + ), + ], + }) +} + +fn systemd_user_dir() -> Result { + let config_dir = std::env::var_os("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(dirs::config_dir) + .or_else(|| dirs::home_dir().map(|home| home.join(".config"))); + Ok(config_dir + .context("Could not determine XDG config directory")? + .join("systemd") + .join("user")) +} + +fn render_cron_spec(exe: &Path, settings: &AutosubmitSettings) -> Result { + let log_path = autosubmit_log_path()?; + let interval = settings.interval_minutes.max(1); + let schedule = if interval < 60 { + format!("*/{interval} * * * *") + } else { + "0 * * * *".to_string() + }; + let line = format!( + "{schedule} {} autosubmit run >> {} 2>&1", + shell_quote(&exe.to_string_lossy()), + shell_quote(&log_path.to_string_lossy()) + ); + let block = format!("{CRON_MARKER_BEGIN}\n{line}\n{CRON_MARKER_END}"); + Ok(SchedulerSpec { + files: Vec::new(), + install_commands: Vec::new(), + uninstall_commands: Vec::new(), + cron_block: Some(block), + }) +} + +fn render_windows_task_spec(exe: &Path, settings: &AutosubmitSettings) -> Result { + let (schedule, modifier) = windows_schedule(settings.interval_minutes)?; + let task = format!(r#""{}" autosubmit run"#, exe.display()); + Ok(SchedulerSpec { + files: Vec::new(), + cron_block: None, + install_commands: vec![( + "schtasks".to_string(), + vec![ + "/Create".to_string(), + "/F".to_string(), + "/SC".to_string(), + schedule, + "/MO".to_string(), + modifier, + "/TN".to_string(), + JOB_ID.to_string(), + "/TR".to_string(), + task, + ], + )], + uninstall_commands: vec![( + "schtasks".to_string(), + vec![ + "/Delete".to_string(), + "/F".to_string(), + "/TN".to_string(), + JOB_ID.to_string(), + ], + )], + }) +} + +fn windows_schedule(interval_minutes: u64) -> Result<(String, String)> { + if interval_minutes < 24 * 60 { + return Ok(("MINUTE".to_string(), interval_minutes.max(1).to_string())); + } + + if interval_minutes.is_multiple_of(24 * 60) { + return Ok(( + "DAILY".to_string(), + (interval_minutes / (24 * 60)).max(1).to_string(), + )); + } + + bail!("Windows Task Scheduler supports autosubmit intervals under 24h or whole-day multiples") +} + +pub fn replace_cron_block(existing: &str, block: &str) -> String { + let mut output = Vec::new(); + let mut inside = false; + for line in existing.lines() { + if line.trim() == CRON_MARKER_BEGIN { + inside = true; + continue; + } + if line.trim() == CRON_MARKER_END { + inside = false; + continue; + } + if !inside { + output.push(line.to_string()); + } + } + output.push(block.to_string()); + output.join("\n") + "\n" +} + +fn install_cron_block(block: &str) -> Result<()> { + let existing = read_crontab().unwrap_or_default(); + let updated = replace_cron_block(&existing, block); + write_crontab(&updated) +} + +fn uninstall_cron_block() -> Result<()> { + let existing = read_crontab().unwrap_or_default(); + let updated = remove_cron_block(&existing); + write_crontab(&updated) +} + +fn remove_cron_block(existing: &str) -> String { + let mut output = Vec::new(); + let mut inside = false; + for line in existing.lines() { + if line.trim() == CRON_MARKER_BEGIN { + inside = true; + continue; + } + if line.trim() == CRON_MARKER_END { + inside = false; + continue; + } + if !inside { + output.push(line.to_string()); + } + } + if output.is_empty() { + String::new() + } else { + output.join("\n") + "\n" + } +} + +fn read_crontab() -> Result { + let output = Command::new("crontab").arg("-l").output()?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Ok(String::new()) + } +} + +fn write_crontab(content: &str) -> Result<()> { + use std::io::Write; + let mut child = Command::new("crontab") + .arg("-") + .stdin(std::process::Stdio::piped()) + .spawn()?; + if let Some(stdin) = child.stdin.as_mut() { + stdin.write_all(content.as_bytes())?; + } + let status = child.wait()?; + if !status.success() { + bail!("crontab exited with status {status}"); + } + Ok(()) +} + +fn run_status_command(program: &str, args: &[String]) -> Result<()> { + let status = Command::new(program).args(args).status()?; + if !status.success() { + bail!("{program} exited with status {status}"); + } + Ok(()) +} + +fn autosubmit_log_path() -> Result { + let dir = crate::paths::get_config_dir().join("autosubmit"); + fs::create_dir_all(&dir)?; + Ok(dir.join("autosubmit.log")) +} + +fn autosubmit_lock_path() -> Result { + let dir = crate::paths::get_config_dir().join("autosubmit"); + fs::create_dir_all(&dir)?; + Ok(dir.join("autosubmit.lock")) +} + +fn validate_scheduler_executable(path: &Path) -> Result<()> { + let rendered = path.to_string_lossy(); + if rendered.contains('\n') || rendered.contains('\r') || rendered.contains('\0') { + bail!("Executable path contains unsupported control characters"); + } + Ok(()) +} + +pub fn format_timestamp_ms(timestamp_ms: i64) -> String { + chrono::DateTime::::from_timestamp_millis(timestamp_ms) + .map(|timestamp| timestamp.to_rfc3339()) + .unwrap_or_else(|| timestamp_ms.to_string()) +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r#"'\''"#)) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn systemd_escape_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "\\\\") + .replace(' ', "\\x20") +} + +fn build_date_filter_for_date( + date: &DateRangeFlags, + current_date: chrono::NaiveDate, +) -> (Option, Option) { + use chrono::{Datelike, Duration}; + + if date.today { + let day = current_date.format("%Y-%m-%d").to_string(); + return (Some(day.clone()), Some(day)); + } + if date.yesterday { + let day = (current_date - Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + return (Some(day.clone()), Some(day)); + } + if date.week { + let start = current_date - Duration::days(6); + return ( + Some(start.format("%Y-%m-%d").to_string()), + Some(current_date.format("%Y-%m-%d").to_string()), + ); + } + if date.month { + let start = current_date.with_day(1).unwrap_or(current_date); + return ( + Some(start.format("%Y-%m-%d").to_string()), + Some(current_date.format("%Y-%m-%d").to_string()), + ); + } + (date.since.clone(), date.until.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::ffi::{OsStr, OsString}; + use tempfile::TempDir; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = env::var_os(key); + env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + + #[test] + fn parses_bounded_intervals() { + assert_eq!(parse_interval_minutes("15m").unwrap(), 15); + assert_eq!(parse_interval_minutes("2h").unwrap(), 120); + assert_eq!(parse_interval_minutes("1d").unwrap(), 1440); + assert!(parse_interval_minutes("14m").is_err()); + assert!(parse_interval_minutes("8d").is_err()); + assert!(parse_interval_minutes("1w").is_err()); + } + + #[test] + fn run_decision_respects_interval_and_force() { + let settings = AutosubmitSettings { + enabled: true, + interval_minutes: 60, + last_run_at_ms: Some(1_000), + ..AutosubmitSettings::default() + }; + assert_eq!( + run_decision(&settings, 30_000, false), + AutosubmitRunDecision::NotDue { + next_run_at_ms: 3_601_000 + } + ); + assert_eq!( + run_decision(&settings, 30_000, true), + AutosubmitRunDecision::Due + ); + assert_eq!( + run_decision(&settings, 3_601_000, false), + AutosubmitRunDecision::Due + ); + } + + #[test] + fn clients_for_settings_keep_empty_as_submit_default_marker() { + let settings_clients = clients_for_settings(ClientFlags::default()); + assert!(settings_clients.is_empty()); + } + + #[test] + fn cron_block_replacement_preserves_unrelated_jobs() { + let existing = + "0 0 * * * echo keep\n# BEGIN TOKSCALE AUTOSUBMIT\nold\n# END TOKSCALE AUTOSUBMIT\n"; + let updated = replace_cron_block( + existing, + "# BEGIN TOKSCALE AUTOSUBMIT\nnew\n# END TOKSCALE AUTOSUBMIT", + ); + assert!(updated.contains("0 0 * * * echo keep")); + assert!(updated.contains("new")); + assert!(!updated.contains("old")); + } + + #[test] + fn launchd_spec_uses_program_arguments_without_shell() { + let settings = AutosubmitSettings { + interval_minutes: 60, + ..AutosubmitSettings::default() + }; + let spec = render_launchd_spec(Path::new("/usr/local/bin/tokscale"), &settings).unwrap(); + let content = &spec.files[0].1; + assert!(content.contains("/usr/local/bin/tokscale")); + assert!(content.contains("autosubmit")); + assert!(content.contains("run")); + assert!(!content.contains("/bin/sh")); + } + + #[test] + fn systemd_spec_uses_autosubmit_run() { + let settings = AutosubmitSettings { + interval_minutes: 120, + ..AutosubmitSettings::default() + }; + let spec = render_systemd_spec(Path::new("/usr/local/bin/tokscale"), &settings).unwrap(); + let service = &spec.files[0].1; + let timer = &spec.files[1].1; + assert!(service.contains("ExecStart=/usr/local/bin/tokscale autosubmit run")); + assert!(timer.contains("OnUnitActiveSec=120min")); + } + + #[test] + #[serial_test::serial] + fn systemd_spec_honors_xdg_config_home() { + let temp = TempDir::new().unwrap(); + let _guard = EnvVarGuard::set("XDG_CONFIG_HOME", temp.path()); + let settings = AutosubmitSettings::default(); + + let spec = render_systemd_spec(Path::new("/usr/local/bin/tokscale"), &settings).unwrap(); + + assert_eq!( + spec.files[0].0, + temp.path() + .join("systemd") + .join("user") + .join("tokscale-autosubmit.service") + ); + assert_eq!( + spec.files[1].0, + temp.path() + .join("systemd") + .join("user") + .join("tokscale-autosubmit.timer") + ); + } + + #[test] + fn windows_spec_uses_fixed_task_name() { + let settings = AutosubmitSettings { + interval_minutes: 30, + ..AutosubmitSettings::default() + }; + let spec = render_windows_task_spec(Path::new("C:/bin/tokscale.exe"), &settings).unwrap(); + let args = &spec.install_commands[0].1; + assert!(args.iter().any(|arg| arg == JOB_ID)); + assert!(args.iter().any(|arg| arg.contains("autosubmit run"))); + assert!(args + .windows(2) + .any(|pair| pair[0] == "/SC" && pair[1] == "MINUTE")); + assert!(args + .windows(2) + .any(|pair| pair[0] == "/MO" && pair[1] == "30")); + } + + #[test] + fn windows_spec_uses_daily_schedule_for_default_interval() { + let spec = render_windows_task_spec( + Path::new("C:/bin/tokscale.exe"), + &AutosubmitSettings::default(), + ) + .unwrap(); + let args = &spec.install_commands[0].1; + assert!(args + .windows(2) + .any(|pair| pair[0] == "/SC" && pair[1] == "DAILY")); + assert!(args + .windows(2) + .any(|pair| pair[0] == "/MO" && pair[1] == "1")); + } + + #[test] + fn windows_spec_rejects_long_non_day_interval() { + let settings = AutosubmitSettings { + interval_minutes: 25 * 60, + ..AutosubmitSettings::default() + }; + + let err = render_windows_task_spec(Path::new("C:/bin/tokscale.exe"), &settings) + .expect_err("25h is not representable by schtasks minute or daily cadence"); + assert!(err.to_string().contains("whole-day multiples")); + } + + #[test] + fn submit_filters_keep_absolute_date_filters() { + let settings = AutosubmitSettings { + clients: vec!["opencode".to_string(), "claude".to_string()], + since: Some("2026-01-01".to_string()), + until: Some("2026-01-31".to_string()), + ..AutosubmitSettings::default() + }; + + let (clients, since, until, year) = submit_filters(&settings); + + assert_eq!( + clients, + Some(vec!["opencode".to_string(), "claude".to_string()]) + ); + assert_eq!(since.as_deref(), Some("2026-01-01")); + assert_eq!(until.as_deref(), Some("2026-01-31")); + assert_eq!(year, None); + } + + #[test] + fn submit_filters_default_to_submit_clients_when_unfiltered() { + let settings = AutosubmitSettings::default(); + + let (clients, _, _, _) = submit_filters(&settings); + + let clients = clients.unwrap(); + assert!(clients.contains(&"opencode".to_string())); + assert!(clients.contains(&"synthetic".to_string())); + assert!(!clients.contains(&"warp".to_string())); + } + + #[test] + #[serial_test::serial] + fn run_lock_blocks_concurrent_holder() { + let temp = TempDir::new().unwrap(); + let _guard = EnvVarGuard::set("TOKSCALE_CONFIG_DIR", temp.path()); + + let first = try_acquire_run_lock().unwrap().expect("first lock"); + assert!(try_acquire_run_lock().unwrap().is_none()); + drop(first); + assert!(try_acquire_run_lock().unwrap().is_some()); + } +} diff --git a/crates/tokscale-cli/src/commands/codex_activity.rs b/crates/tokscale-cli/src/commands/codex_activity.rs new file mode 100644 index 000000000..ab93da31a --- /dev/null +++ b/crates/tokscale-cli/src/commands/codex_activity.rs @@ -0,0 +1,703 @@ +use anyhow::Result; +use chrono::{NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::mpsc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +const APP_SERVER_TIMEOUT: Duration = Duration::from_secs(10); +const INITIALIZE_REQUEST_ID: i64 = 1; +const ACTIVITY_REQUEST_ID: i64 = 2; +const MAX_JSONL_LINE_BYTES: usize = 1024 * 1024; +const MAX_PENDING_FRAMES: usize = 16; +const APP_SERVER_SOURCE: &str = "codex-app-server"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum CodexAccountActivityStatus { + Available, + UnsupportedCli, + UnsupportedAuth, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexAccountActivitySnapshot { + pub status: CodexAccountActivityStatus, + pub source: &'static str, + pub lifetime_tokens: Option, + pub peak_daily_tokens: Option, + pub longest_running_turn_sec: Option, + pub current_streak_days: Option, + pub longest_streak_days: Option, + pub daily_usage_buckets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub fetched_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexAccountActivityDailyBucket { + pub start_date: String, + pub tokens: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppServerActivityResult { + #[serde(default)] + summary: Option, + #[serde(default)] + daily_usage_buckets: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppServerActivitySummary { + lifetime_tokens: Option, + peak_daily_tokens: Option, + longest_running_turn_sec: Option, + current_streak_days: Option, + longest_streak_days: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AppServerDailyUsageBucket { + start_date: String, + tokens: u64, +} + +#[derive(Debug)] +enum ActivityFetchError { + UnsupportedCli, + UnsupportedAuth, + Unavailable(&'static str), +} + +impl ActivityFetchError { + fn snapshot(self) -> CodexAccountActivitySnapshot { + let (status, message) = match self { + Self::UnsupportedCli => ( + CodexAccountActivityStatus::UnsupportedCli, + "The installed Codex CLI does not support account activity.".to_string(), + ), + Self::UnsupportedAuth => ( + CodexAccountActivityStatus::UnsupportedAuth, + "Codex account activity requires supported Codex-service authentication." + .to_string(), + ), + Self::Unavailable(message) => (CodexAccountActivityStatus::Unavailable, message.into()), + }; + + CodexAccountActivitySnapshot { + status, + source: APP_SERVER_SOURCE, + lifetime_tokens: None, + peak_daily_tokens: None, + longest_running_turn_sec: None, + current_streak_days: None, + longest_streak_days: None, + daily_usage_buckets: None, + fetched_at: None, + message: Some(message), + } + } +} + +enum AppServerFrame { + Json(String), + Oversized, + InvalidUtf8, +} + +struct AppServerTransport { + child: Child, + stdin: ChildStdin, + frames: Option>, + stdout_reader: Option>, + stderr_reader: Option>, +} + +impl AppServerTransport { + fn spawn() -> std::result::Result { + let mut child = Command::new("codex") + .args(["app-server", "--stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => ActivityFetchError::UnsupportedCli, + _ => ActivityFetchError::Unavailable("Could not start Codex app-server."), + })?; + + let stdin = child.stdin.take(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let (Some(stdin), Some(stdout), Some(stderr)) = (stdin, stdout, stderr) else { + let _ = child.kill(); + let _ = child.wait(); + return Err(ActivityFetchError::Unavailable( + "Codex app-server did not expose its required standard streams.", + )); + }; + let (sender, frames) = mpsc::sync_channel(MAX_PENDING_FRAMES); + + Ok(Self { + child, + stdin, + frames: Some(frames), + stdout_reader: Some(spawn_stdout_reader(stdout, sender)), + stderr_reader: Some(spawn_stderr_drain(stderr)), + }) + } + + fn write_message(&mut self, message: &Value) -> std::result::Result<(), ActivityFetchError> { + serde_json::to_writer(&mut self.stdin, message).map_err(|_| { + ActivityFetchError::Unavailable("Could not encode Codex app-server input.") + })?; + self.stdin + .write_all(b"\n") + .and_then(|_| self.stdin.flush()) + .map_err(|_| ActivityFetchError::Unavailable("Codex app-server closed its input.")) + } + + fn wait_for_response( + &mut self, + expected_id: i64, + deadline: Instant, + ) -> std::result::Result { + loop { + let remaining = deadline.checked_duration_since(Instant::now()).ok_or( + ActivityFetchError::Unavailable("Timed out waiting for Codex account activity."), + )?; + let frame = self + .frames + .as_ref() + .ok_or(ActivityFetchError::Unavailable( + "Codex app-server closed before returning account activity.", + ))? + .recv_timeout(remaining) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => ActivityFetchError::Unavailable( + "Timed out waiting for Codex account activity.", + ), + mpsc::RecvTimeoutError::Disconnected => ActivityFetchError::Unavailable( + "Codex app-server closed before returning account activity.", + ), + })?; + let line = match frame { + AppServerFrame::Json(line) => line, + AppServerFrame::Oversized => { + return Err(ActivityFetchError::Unavailable( + "Codex app-server returned an oversized protocol message.", + )); + } + AppServerFrame::InvalidUtf8 => { + return Err(ActivityFetchError::Unavailable( + "Codex app-server returned a non-text protocol message.", + )); + } + }; + if line.trim().is_empty() { + continue; + } + let message = serde_json::from_str::(&line).map_err(|_| { + ActivityFetchError::Unavailable( + "Codex app-server returned an invalid protocol message.", + ) + })?; + + if is_server_request(&message) { + self.write_message(&unsupported_server_request_response(&message))?; + continue; + } + + if message.get("id").and_then(Value::as_i64) == Some(expected_id) { + return Ok(message); + } + } + } +} + +impl Drop for AppServerTransport { + fn drop(&mut self) { + // Drop the receiver before joining stdout so a blocked bounded sender can exit. + self.frames.take(); + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(reader) = self.stdout_reader.take() { + let _ = reader.join(); + } + if let Some(reader) = self.stderr_reader.take() { + let _ = reader.join(); + } + } +} + +fn spawn_stdout_reader( + stdout: ChildStdout, + sender: mpsc::SyncSender, +) -> JoinHandle<()> { + thread::spawn(move || { + let mut reader = BufReader::new(stdout); + while let Ok(Some(frame)) = read_jsonl_frame(&mut reader) { + if sender.send(frame).is_err() { + return; + } + } + }) +} + +fn spawn_stderr_drain(mut stderr: ChildStderr) -> JoinHandle<()> { + thread::spawn(move || { + let _ = std::io::copy(&mut stderr, &mut std::io::sink()); + }) +} + +fn read_jsonl_frame(reader: &mut R) -> std::io::Result> { + let mut bytes = Vec::new(); + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return if bytes.is_empty() { + Ok(None) + } else { + Ok(Some(frame_from_bytes(bytes))) + }; + } + + let newline = chunk.iter().position(|byte| *byte == b'\n'); + let content_len = newline.unwrap_or(chunk.len()); + if bytes.len().saturating_add(content_len) > MAX_JSONL_LINE_BYTES { + let consumed = newline.map_or(chunk.len(), |index| index + 1); + reader.consume(consumed); + if newline.is_none() { + discard_until_newline(reader)?; + } + return Ok(Some(AppServerFrame::Oversized)); + } + + bytes.extend_from_slice(&chunk[..content_len]); + let consumed = newline.map_or(chunk.len(), |index| index + 1); + reader.consume(consumed); + if newline.is_some() { + return Ok(Some(frame_from_bytes(bytes))); + } + } +} + +fn discard_until_newline(reader: &mut R) -> std::io::Result<()> { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return Ok(()); + } + if let Some(index) = chunk.iter().position(|byte| *byte == b'\n') { + reader.consume(index + 1); + return Ok(()); + } + let len = chunk.len(); + reader.consume(len); + } +} + +fn frame_from_bytes(bytes: Vec) -> AppServerFrame { + match String::from_utf8(bytes) { + Ok(line) => AppServerFrame::Json(line), + Err(_) => AppServerFrame::InvalidUtf8, + } +} + +fn is_server_request(message: &Value) -> bool { + message.get("method").and_then(Value::as_str).is_some() + && message.get("id").is_some() + && message.get("result").is_none() + && message.get("error").is_none() +} + +fn unsupported_server_request_response(message: &Value) -> Value { + serde_json::json!({ + "id": message.get("id").cloned().unwrap_or(Value::Null), + "error": { + "code": -32601, + "message": "Method not supported by tokscale" + } + }) +} + +fn initialize_request() -> Value { + serde_json::json!({ + "id": INITIALIZE_REQUEST_ID, + "method": "initialize", + "params": { + "clientInfo": { + "name": "tokscale", + "title": "Tokscale", + "version": env!("CARGO_PKG_VERSION") + } + } + }) +} + +fn initialized_notification() -> Value { + serde_json::json!({ + "method": "initialized", + "params": {} + }) +} + +fn activity_request() -> Value { + serde_json::json!({ + "id": ACTIVITY_REQUEST_ID, + "method": "account/usage/read" + }) +} + +fn fetch_activity_from_app_server( +) -> std::result::Result { + let mut transport = AppServerTransport::spawn()?; + let deadline = Instant::now() + APP_SERVER_TIMEOUT; + + transport.write_message(&initialize_request())?; + let initialize_response = transport.wait_for_response(INITIALIZE_REQUEST_ID, deadline)?; + if initialize_response.get("error").is_some() { + return Err(classify_rpc_error(&initialize_response)); + } + if initialize_response.get("result").is_none() { + return Err(ActivityFetchError::UnsupportedCli); + } + + transport.write_message(&initialized_notification())?; + transport.write_message(&activity_request())?; + let activity_response = transport.wait_for_response(ACTIVITY_REQUEST_ID, deadline)?; + if activity_response.get("error").is_some() { + return Err(classify_rpc_error(&activity_response)); + } + let result = activity_response + .get("result") + .ok_or(ActivityFetchError::Unavailable( + "Codex app-server returned an invalid account activity response.", + ))?; + + parse_activity_result(result) +} + +fn classify_rpc_error(response: &Value) -> ActivityFetchError { + let error = response.get("error"); + let code = error + .and_then(|error| error.get("code")) + .and_then(Value::as_i64); + if code == Some(-32601) { + return ActivityFetchError::UnsupportedCli; + } + + let message = error + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if message.contains("auth") + || message.contains("sign in") + || message.contains("login") + || message.contains("not authenticated") + { + return ActivityFetchError::UnsupportedAuth; + } + + ActivityFetchError::Unavailable("Codex app-server could not read account activity.") +} + +fn parse_activity_result( + result: &Value, +) -> std::result::Result { + let activity: AppServerActivityResult = + serde_json::from_value(result.clone()).map_err(|_| { + ActivityFetchError::Unavailable( + "Codex app-server returned an invalid account activity response.", + ) + })?; + let daily_usage_buckets = activity + .daily_usage_buckets + .map(|buckets| { + buckets + .into_iter() + .map(|bucket| { + NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d").map_err(|_| { + ActivityFetchError::Unavailable( + "Codex app-server returned an invalid daily activity date.", + ) + })?; + Ok(CodexAccountActivityDailyBucket { + start_date: bucket.start_date, + tokens: bucket.tokens, + }) + }) + .collect::, _>>() + }) + .transpose()?; + let summary = activity.summary; + + Ok(CodexAccountActivitySnapshot { + status: CodexAccountActivityStatus::Available, + source: APP_SERVER_SOURCE, + lifetime_tokens: summary.as_ref().and_then(|summary| summary.lifetime_tokens), + peak_daily_tokens: summary + .as_ref() + .and_then(|summary| summary.peak_daily_tokens), + longest_running_turn_sec: summary + .as_ref() + .and_then(|summary| summary.longest_running_turn_sec), + current_streak_days: summary + .as_ref() + .and_then(|summary| summary.current_streak_days), + longest_streak_days: summary + .as_ref() + .and_then(|summary| summary.longest_streak_days), + daily_usage_buckets, + fetched_at: Some(Utc::now().to_rfc3339()), + message: None, + }) +} + +pub fn run(json: bool) -> Result<()> { + let activity = match fetch_activity_from_app_server() { + Ok(activity) => activity, + Err(error) => error.snapshot(), + }; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&activity_json(&activity))? + ); + return Ok(()); + } + + render_activity(&activity); + Ok(()) +} + +fn activity_json(activity: &CodexAccountActivitySnapshot) -> Value { + serde_json::json!({ + "codexAccountActivity": activity, + }) +} + +fn render_activity(activity: &CodexAccountActivitySnapshot) { + use colored::Colorize; + + println!("\n {}\n", "Codex - Account activity (supplemental)".cyan()); + println!( + " {}", + format!("Source: {}", activity.source).bright_black() + ); + println!(" {}", "Cost: N/A".bright_black()); + match activity.status { + CodexAccountActivityStatus::Available => { + if let Some(fetched_at) = &activity.fetched_at { + println!(" {}", format!("Fetched: {fetched_at}").bright_black()); + } + render_optional_count("Lifetime tokens", activity.lifetime_tokens); + render_optional_count("Peak daily tokens", activity.peak_daily_tokens); + render_optional_count("Longest turn (seconds)", activity.longest_running_turn_sec); + render_optional_count("Current streak (days)", activity.current_streak_days); + render_optional_count("Longest streak (days)", activity.longest_streak_days); + if let Some(buckets) = &activity.daily_usage_buckets { + if buckets.is_empty() { + println!(" {}", "Daily buckets: none returned".bright_black()); + } else { + println!(" {}", "Daily activity:".bright_black()); + for bucket in buckets { + println!(" {} {}", bucket.start_date, bucket.tokens); + } + } + } + } + _ => { + if let Some(message) = &activity.message { + println!(" {}", message.yellow()); + } + } + } + println!( + "{}\n", + " Not included in local totals, reports, exports, or submissions.".bright_black() + ); +} + +fn render_optional_count(label: &str, value: Option) { + use colored::Colorize; + + if let Some(value) = value { + println!(" {}", format!("{label}: {value}").white()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_complete_activity_result() { + let snapshot = parse_activity_result(&serde_json::json!({ + "summary": { + "lifetimeTokens": 1234567, + "peakDailyTokens": 45678, + "longestRunningTurnSec": 540, + "currentStreakDays": 8, + "longestStreakDays": 14 + }, + "dailyUsageBuckets": [ + {"startDate": "2026-06-18", "tokens": 12345} + ] + })) + .unwrap(); + + assert_eq!(snapshot.status, CodexAccountActivityStatus::Available); + assert_eq!(snapshot.source, APP_SERVER_SOURCE); + assert_eq!(snapshot.lifetime_tokens, Some(1_234_567)); + assert_eq!(snapshot.peak_daily_tokens, Some(45_678)); + assert_eq!( + snapshot.daily_usage_buckets, + Some(vec![CodexAccountActivityDailyBucket { + start_date: "2026-06-18".into(), + tokens: 12_345, + }]) + ); + assert!(snapshot.fetched_at.is_some()); + } + + #[test] + fn parses_nullable_activity_result_without_inventing_totals() { + let snapshot = parse_activity_result(&serde_json::json!({ + "summary": { + "lifetimeTokens": null, + "peakDailyTokens": null, + "longestRunningTurnSec": null, + "currentStreakDays": null, + "longestStreakDays": null + }, + "dailyUsageBuckets": null + })) + .unwrap(); + + let json = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(snapshot.status, CodexAccountActivityStatus::Available); + assert!(snapshot.lifetime_tokens.is_none()); + assert!(snapshot.daily_usage_buckets.is_none()); + assert!(json.get("totalTokens").is_none()); + assert!(json.get("cost").is_none()); + } + + #[test] + fn rejects_invalid_activity_values() { + let negative = parse_activity_result(&serde_json::json!({ + "summary": {"lifetimeTokens": -1} + })); + assert!(matches!(negative, Err(ActivityFetchError::Unavailable(_)))); + + let invalid_date = parse_activity_result(&serde_json::json!({ + "dailyUsageBuckets": [{"startDate": "not-a-date", "tokens": 1}] + })); + assert!(matches!( + invalid_date, + Err(ActivityFetchError::Unavailable(_)) + )); + } + + #[test] + fn classifies_rpc_errors_without_exposing_server_text() { + assert!(matches!( + classify_rpc_error(&serde_json::json!({ + "error": {"code": -32601, "message": "Method not found"} + })), + ActivityFetchError::UnsupportedCli + )); + assert!(matches!( + classify_rpc_error(&serde_json::json!({ + "error": {"code": -32000, "message": "Not authenticated"} + })), + ActivityFetchError::UnsupportedAuth + )); + let snapshot = classify_rpc_error(&serde_json::json!({ + "error": {"code": -32000, "message": "private detail"} + })) + .snapshot(); + assert_eq!( + snapshot.message.as_deref(), + Some("Codex app-server could not read account activity.") + ); + } + + #[test] + fn unavailable_snapshot_is_stable_and_has_no_fetch_time() { + let snapshot = ActivityFetchError::UnsupportedCli.snapshot(); + let json = serde_json::to_value(&snapshot).unwrap(); + + assert_eq!(json["status"], "unsupportedCli"); + assert_eq!(json["source"], APP_SERVER_SOURCE); + assert!(json.get("fetchedAt").is_none()); + assert!(json.get("cost").is_none()); + assert!(json.get("totalTokens").is_none()); + } + + #[test] + fn activity_json_uses_a_standalone_wrapper() { + let snapshot = ActivityFetchError::UnsupportedCli.snapshot(); + let json = activity_json(&snapshot); + + assert_eq!(json["codexAccountActivity"]["status"], "unsupportedCli"); + assert!(json.get("totalTokens").is_none()); + assert!(json.get("cost").is_none()); + } + + #[test] + fn protocol_messages_follow_the_required_handshake() { + let initialize = initialize_request(); + let initialized = initialized_notification(); + let activity = activity_request(); + + assert_eq!(initialize.get("id").and_then(Value::as_i64), Some(1)); + assert_eq!(initialize["method"], "initialize"); + assert_eq!(initialized["method"], "initialized"); + assert!(initialized.get("id").is_none()); + assert_eq!(activity.get("id").and_then(Value::as_i64), Some(2)); + assert_eq!(activity["method"], "account/usage/read"); + assert!(initialize.get("jsonrpc").is_none()); + } + + #[test] + fn recognizes_and_rejects_server_requests() { + let request = serde_json::json!({"id": 9, "method": "attestation/generate"}); + + assert!(is_server_request(&request)); + assert_eq!( + unsupported_server_request_response(&request), + serde_json::json!({ + "id": 9, + "error": {"code": -32601, "message": "Method not supported by tokscale"} + }) + ); + } + + #[test] + fn rejects_oversized_jsonl_frames() { + let oversized = vec![b'x'; MAX_JSONL_LINE_BYTES + 1]; + let mut input = oversized; + input.push(b'\n'); + let mut reader = BufReader::new(input.as_slice()); + + assert!(matches!( + read_jsonl_frame(&mut reader).unwrap(), + Some(AppServerFrame::Oversized) + )); + } +} diff --git a/crates/tokscale-cli/src/commands/mod.rs b/crates/tokscale-cli/src/commands/mod.rs index 06b9ec621..6cb1e129c 100644 --- a/crates/tokscale-cli/src/commands/mod.rs +++ b/crates/tokscale-cli/src/commands/mod.rs @@ -1,2 +1,6 @@ +pub mod apple_fm; +pub mod autosubmit; +pub mod codex_activity; +pub mod report; pub mod usage; pub mod wrapped; diff --git a/crates/tokscale-cli/src/commands/report.rs b/crates/tokscale-cli/src/commands/report.rs new file mode 100644 index 000000000..c68306b39 --- /dev/null +++ b/crates/tokscale-cli/src/commands/report.rs @@ -0,0 +1,2102 @@ +use anyhow::Result; +use chrono::{Local, TimeZone}; +use colored::Colorize; +use std::collections::HashMap; +use std::io::Write; +use std::process::{Command, Output, Stdio}; +use unicode_normalization::UnicodeNormalization; + +use super::apple_fm; +use std::path::PathBuf; +use std::time::Duration; +use tokscale_core::content_extractor::SessionContent; +use tokscale_core::content_extractor::{extract_session_content, metadata_only_content}; +use tokscale_core::pricing::PricingService; +use tokscale_core::wiki::{WikiDb, WikiEntry}; +use tokscale_core::{parse_local_clients, LocalParseOptions, ParsedMessage, TokenBreakdown}; + +pub struct ReportOptions { + pub json: bool, + pub since: Option, + pub until: Option, + pub workspace: Option, + pub client: Option, + pub no_summarize: bool, + pub summarizer: String, + pub rebuild: bool, + pub home_dir: Option, + pub scanner_settings: tokscale_core::scanner::ScannerSettings, + pub today: bool, + pub week: bool, + pub month: bool, + pub full: bool, +} + +pub fn run_report(opts: ReportOptions) -> Result<()> { + let wiki_path = WikiDb::default_path(); + let db = + WikiDb::open(&wiki_path).map_err(|e| anyhow::anyhow!("Failed to open wiki DB: {}", e))?; + + populate_wiki_from_sessions(&db, &opts)?; + + let (since_ts, until_ts) = parse_date_range(&opts.since, &opts.until); + + if opts.rebuild { + let count = db + .reset_summaries_in_range(since_ts, until_ts) + .map_err(|e| anyhow::anyhow!("{}", e))?; + eprintln!(" Reset {} session summaries", count.to_string().cyan()); + } + + let unsummarized = if opts.no_summarize { + Vec::new() + } else { + db.get_unsummarized_session_ids_in_range(since_ts, until_ts) + .map_err(|e| anyhow::anyhow!("{}", e))? + }; + + if !unsummarized.is_empty() { + let session_paths = build_session_path_index(&opts); + run_summarizer(&db, &unsummarized, &opts.summarizer, &session_paths)?; + } + + let entries = db + .query_entries( + since_ts, + until_ts, + opts.workspace.as_deref(), + opts.client.as_deref(), + ) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let needs_grouping = entries + .iter() + .any(|e| e.title.is_some() && e.task_group.is_none()); + if needs_grouping && !opts.no_summarize { + run_task_grouping(&db, &entries, &opts.summarizer)?; + let entries = db + .query_entries( + since_ts, + until_ts, + opts.workspace.as_deref(), + opts.client.as_deref(), + ) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + if opts.json { + let json = serde_json::to_string_pretty(&entries)?; + println!("{}", json); + } else { + let is_multi_day = opts.week || opts.month || (opts.since.is_some() && !opts.today); + print_report_table(&entries, &db, is_multi_day, opts.full)?; + } + } else if opts.json { + let json = serde_json::to_string_pretty(&entries)?; + println!("{}", json); + } else { + let is_multi_day = opts.week || opts.month || (opts.since.is_some() && !opts.today); + print_report_table(&entries, &db, is_multi_day, opts.full)?; + } + + Ok(()) +} + +fn populate_wiki_from_sessions(db: &WikiDb, opts: &ReportOptions) -> Result<()> { + let existing = db + .get_existing_session_ids() + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let parsed = parse_local_clients(LocalParseOptions { + home_dir: opts.home_dir.clone(), + use_env_roots: opts.home_dir.is_none(), + clients: None, + since: None, + until: None, + year: None, + scanner_settings: opts.scanner_settings.clone(), + }) + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let pricing = load_pricing_service(); + + let mut session_map: HashMap = HashMap::new(); + + for msg in &parsed.messages { + let agg = session_map + .entry(msg.session_id.clone()) + .or_insert_with(|| SessionAgg { + client: msg.client.clone(), + workspace: msg.workspace_key.clone(), + workspace_label: msg.workspace_label.clone(), + created_at: msg.timestamp, + last_active: msg.timestamp, + total_input: 0, + total_output: 0, + total_cache_read: 0, + total_cost: 0.0, + models: HashMap::new(), + message_count: 0, + }); + + agg.last_active = agg.last_active.max(msg.timestamp); + agg.created_at = agg.created_at.min(msg.timestamp); + // saturating: per-message token fields from a corrupt source can be + // clamped to i64::MAX (see tokscale-core), so plain `+=` can overflow. + agg.total_input = agg.total_input.saturating_add(msg.input); + agg.total_output = agg.total_output.saturating_add(msg.output); + agg.total_cache_read = agg.total_cache_read.saturating_add(msg.cache_read); + agg.total_cost += compute_msg_cost(msg, pricing.as_deref()); + // NOTE: the wiki `report` view intentionally groups on the raw model_id + // and does not apply `modelAliases` folding (nor the grouping + // normalization every other report uses). Wiki entries are persisted + // append-only — previously-recorded sessions are not rewritten (see the + // `existing.contains` skip below) — so folding here would leave a mix of + // raw and canonical names across sessions recorded before vs after + // aliases were configured. To fold in a future change, wrap the key with + // `tokscale_core::normalize_model_for_grouping(&msg.model_id)` here and in + // the by-model/daily/session/JSON surfaces. + *agg.models.entry(msg.model_id.clone()).or_insert(0) += 1; + agg.message_count += msg.message_count; + } + + let mut new_count = 0; + for (session_id, agg) in &session_map { + if existing.contains(session_id) { + continue; + } + + let models_used: Vec = agg.models.keys().cloned().collect(); + let duration_minutes = (agg.last_active - agg.created_at) / 60; + + let entry = WikiEntry { + session_id: session_id.clone(), + client: agg.client.clone(), + workspace: agg.workspace.clone(), + workspace_label: agg.workspace_label.clone(), + created_at: agg.created_at, + last_active: agg.last_active, + title: None, + task_category: None, + description: None, + complexity: None, + task_group: None, + total_input_tokens: agg.total_input, + total_output_tokens: agg.total_output, + total_cache_read: agg.total_cache_read, + total_cost: agg.total_cost, + models_used, + message_count: agg.message_count, + duration_minutes, + summarized_at: None, + fm_version: None, + }; + + db.upsert_entry(&entry) + .map_err(|e| anyhow::anyhow!("{}", e))?; + new_count += 1; + } + + if new_count > 0 { + eprintln!( + " {} new sessions added to wiki", + new_count.to_string().cyan() + ); + } + + Ok(()) +} + +fn run_summarizer( + db: &WikiDb, + session_ids: &[String], + backend: &str, + session_paths: &SessionPathIndex, +) -> Result<()> { + let mut payloads: Vec = Vec::new(); + for sid in session_ids { + if let Ok(Some(entry)) = db.get_entry(sid) { + let content = extract_content_for_session(&entry, session_paths); + payloads.push(serde_json::json!({ + "session_id": entry.session_id, + "client": entry.client, + "workspace": entry.workspace.unwrap_or_default(), + "first_user_message": content.first_user_message, + "models_used": entry.models_used, + "total_tokens": entry.total_input_tokens.saturating_add(entry.total_output_tokens), + "duration_minutes": entry.duration_minutes, + "message_count": entry.message_count, + })); + } + } + + if payloads.is_empty() { + return Ok(()); + } + + eprintln!( + " Summarizing {} sessions with {}...", + payloads.len().to_string().cyan(), + backend.cyan() + ); + + // apple-fm runs each session as a self-contained on-device generation, so a + // single giant chunk would suppress the per-batch progress indicator below + // (it's gated on `batch_size < payloads.len()`). Use a modest batch size so + // the "\r Batch i/total" line fires and 100+ sequential on-device calls + // show visible progress instead of hanging silent until the very end. + // Re-fetching the model + rebuilding the schema per small batch is cheap; + // the generation dominates. + let batch_size = match backend { + "apple-fm" => 8, + _ => 20, + }; + + let mut total_summarized = 0; + // Count how many summaries actually came from Apple FM vs the heuristic + // fallback, so a silent total-fallback (e.g. FM unavailable, or every + // generation erroring) is visible rather than reported as plain "N + // summarized". Only meaningful for the apple-fm backend; CLI backends leave + // fm_version null by design. + let mut fm_generated = 0; + for (batch_idx, chunk) in payloads.chunks(batch_size).enumerate() { + if batch_size < payloads.len() { + eprint!( + "\r Batch {}/{} ({} done)...", + batch_idx + 1, + payloads.len().div_ceil(batch_size), + total_summarized + ); + } + + let results = match backend { + "apple-fm" => run_apple_fm_summarizer(chunk)?, + "claude" | "codex" | "gemini" | "kiro" => run_cli_summarizer(backend, chunk)?, + other => { + return Err(anyhow::anyhow!( + "Unknown summarizer backend: '{}'. Valid options: apple-fm, claude, codex, gemini, kiro", + other + )); + } + }; + + for result in &results { + let session_id = result["session_id"].as_str().unwrap_or_default(); + let title = result["title"].as_str().unwrap_or("Untitled"); + let category = result["task_category"].as_str().unwrap_or("other"); + let description = result["description"].as_str().unwrap_or(""); + let complexity = result["complexity"].as_str().unwrap_or("moderate"); + let fm_version = result["fm_version"].as_str(); + if fm_version == Some("apple-fm-on-device") { + fm_generated += 1; + } + + db.update_summary( + session_id, + title, + category, + description, + complexity, + fm_version, + ) + .map_err(|e| anyhow::anyhow!("Failed to save summary for {}: {}", session_id, e))?; + } + + total_summarized += results.len(); + } + + if backend == "apple-fm" { + let heuristic = total_summarized.saturating_sub(fm_generated); + eprintln!( + "\n {} {} sessions summarized ({} via Apple FM, {} heuristic)", + "✓".green(), + total_summarized, + fm_generated, + heuristic + ); + } else { + eprintln!( + "\n {} {} sessions summarized", + "✓".green(), + total_summarized + ); + } + + Ok(()) +} + +const GROUPING_SYSTEM_PROMPT: &str = r#"You are a task grouping assistant. Given a list of coding session titles, group them into high-level project tasks (2-5 words each). + +Rules: +- Group related sessions under a single short label (e.g. "Kiro Auth", "Tokscale Report", "System Config") +- Each group should represent a coherent project or feature area +- Sessions that don't fit any group get their own group name +- Aim for 3-8 groups total. Fewer is better. + +Respond ONLY with a JSON array where each element has: session_id, task_group"#; + +fn run_task_grouping(db: &WikiDb, entries: &[WikiEntry], backend: &str) -> Result<()> { + let summarized: Vec<&WikiEntry> = entries + .iter() + .filter(|e| e.title.is_some() && e.task_group.is_none()) + .collect(); + + if summarized.is_empty() { + return Ok(()); + } + + // Non-CLI backends (apple-fm and any future on-device backend) have no LLM + // grouping path. Rather than skip — which leaves every task_group null and + // makes the report collapse sessions by EXACT title — cluster titles + // deterministically in Rust. This merges near-duplicate titles ("Enhance API + // Security" / "Enhance API security with JWT auth middleware") into a single + // labeled group while keeping unrelated titles apart. + if !matches!(backend, "claude" | "codex" | "gemini" | "kiro") { + let assignments = cluster_titles(&summarized); + let group_count = assignments + .iter() + .map(|(_, label)| label.as_str()) + .collect::>() + .len(); + for (session_id, label) in &assignments { + db.update_task_group(session_id, label).map_err(|e| { + anyhow::anyhow!("Failed to save task_group for {}: {}", session_id, e) + })?; + } + eprintln!( + " {} grouped {} sessions into {} tasks", + "✓".green(), + summarized.len(), + group_count + ); + return Ok(()); + } + + eprint!( + " Grouping {} sessions into tasks...", + summarized.len().to_string().cyan() + ); + + let mut parts = Vec::new(); + parts.push("Group these coding sessions by project/feature:\n".to_string()); + for (i, entry) in summarized.iter().enumerate() { + parts.push(format!( + " {} (id: {}): {} [{}]", + i + 1, + entry.session_id, + entry.title.as_deref().unwrap_or("?"), + entry.workspace.as_deref().unwrap_or("?"), + )); + } + parts.push("\nRespond with a JSON array.".to_string()); + let prompt = parts.join("\n"); + + let cmd = match backend { + "claude" => { + let mut c = Command::new("claude"); + c.args(["-p", "--output-format", "text"]) + .arg(format!("System: {}\n\n{}", GROUPING_SYSTEM_PROMPT, prompt)); + c + } + "codex" => { + let mut c = Command::new("codex"); + c.args(["exec"]) + .arg(format!("{}\n\n{}", GROUPING_SYSTEM_PROMPT, prompt)); + c + } + "gemini" => { + let mut c = Command::new("gemini"); + c.args(["-p"]) + .arg(format!("{}\n\n{}", GROUPING_SYSTEM_PROMPT, prompt)); + c + } + "kiro" => { + let mut c = Command::new("kiro-cli"); + c.args(["chat", "--no-interactive"]) + .arg(format!("{}\n\n{}", GROUPING_SYSTEM_PROMPT, prompt)); + c + } + // Non-CLI backends were already handled by the title-clustering path + // above (which early-returns), so only the four CLI backends reach here. + other => unreachable!("non-CLI backend '{}' must be handled by clustering", other), + }; + + // A timed-out (or otherwise un-spawnable) backend must degrade gracefully: + // skip grouping and continue the report rather than aborting it. + let output = match run_command_with_timeout(cmd, BACKEND_TIMEOUT, None) { + Ok(output) => output, + Err(e) => { + eprintln!("\n {} grouping failed: {}", "⚠".yellow(), e); + return Ok(()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("\n {} grouping failed: {}", "⚠".yellow(), stderr.trim()); + return Ok(()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let json_str = extract_json_array(&stdout); + + match serde_json::from_str::>(json_str) { + Ok(results) => { + for result in &results { + let session_id = result["session_id"].as_str().unwrap_or_default(); + let task_group = result["task_group"].as_str().unwrap_or_default(); + if !session_id.is_empty() && !task_group.is_empty() { + db.update_task_group(session_id, task_group).map_err(|e| { + anyhow::anyhow!("Failed to save task_group for {}: {}", session_id, e) + })?; + } + } + eprintln!(" {}", "✓".green()); + } + Err(e) => { + eprintln!( + "\n {} Failed to parse grouping response: {}", + "⚠".yellow(), + e + ); + } + } + + Ok(()) +} + +/// Generic verbs and stopwords stripped from titles before clustering. These +/// carry no signal about *which* project/feature a session touched (every other +/// session "adds" or "fixes" something), so keeping them would make unrelated +/// titles look similar. +const CLUSTER_STOPWORDS: &[&str] = &[ + "add", + "fix", + "fixes", + "fixed", + "update", + "updates", + "refactor", + "improve", + "implement", + "enhance", + "create", + "remove", + "the", + "a", + "an", + "to", + "for", + "with", + "and", + "of", + "in", + "on", + "via", +]; + +/// Reduce a title to its set of SIGNIFICANT tokens: lowercase, strip +/// punctuation/ellipsis, collapse whitespace, drop generic verbs/stopwords. +/// The returned tokens are deduplicated and sorted so two titles with the same +/// significant words (in any order) produce equal sets. +fn is_combining_mark(c: char) -> bool { + matches!( + c as u32, + 0x0300..=0x036f | 0x1ab0..=0x1aff | 0x1dc0..=0x1dff | 0x20d0..=0x20ff | 0xfe20..=0xfe2f + ) +} + +fn significant_tokens(title: &str) -> Vec { + let mut tokens: Vec = title + // Normalize to NFC FIRST so canonically-equivalent inputs (precomposed + // "é" vs base "e" + combining acute) collapse to the same code points + // before lowercasing and combining-mark stripping. Without this, an NFC + // title kept the precomposed letter while an NFD one had its combining + // mark stripped, tokenizing identical titles differently and splitting + // them across clusters. + .nfc() + .collect::() + .to_lowercase() + .chars() + // Lowercase before filtering: Unicode lowercase can expand a character + // into a base letter plus combining mark (e.g. "İ" -> "i" + dot). + // Dropping combining marks here keeps case-only variants token-equal. + // Other punctuation/ellipsis maps to spaces, stripping trailing "…" or + // "..." that the on-device model often emits. + .filter_map(|c| { + if c.is_alphanumeric() { + Some(c) + } else if is_combining_mark(c) { + None + } else { + Some(' ') + } + }) + .collect::() + .split_whitespace() + .map(|t| t.to_string()) + .filter(|t| !CLUSTER_STOPWORDS.contains(&t.as_str())) + .collect(); + tokens.sort(); + tokens.dedup(); + tokens +} + +/// Normalize a title for exact-equality grouping of token-empty titles: +/// full Unicode lowercase with whitespace collapsed. Used only to keep +/// identical all-stopword titles together while keeping distinct ones apart. +fn normalized_title(title: &str) -> String { + title + // Match significant_tokens: normalize to NFC before lowercasing so + // canonically-equivalent all-stopword titles share an exact key. + .nfc() + .collect::() + .to_lowercase() + .split_whitespace() + .collect::>() + .join(" ") +} + +/// Similarity threshold for treating two titles as the same task. Applied to +/// the overlap coefficient (shared / size-of-smaller-set), which — unlike a raw +/// shared-token count — scales with how much of the smaller title is covered. +const CLUSTER_SIMILARITY_THRESHOLD: f64 = 0.6; + +/// Two token sets are considered the same task when they overlap strongly, +/// measured by the OVERLAP COEFFICIENT: `shared / min(|a|, |b|)`. +/// +/// This deliberately replaces the old "share ≥ 2 tokens" rule, which fired on +/// any two long-but-unrelated titles that happened to share two common words +/// (e.g. "add" + "service") and — combined with union-growing cluster +/// signatures — let a single cluster transitively swallow everything. +/// +/// The overlap coefficient stays high (1.0) when a short title is fully +/// contained in a longer one ("Enhance API Security" ⊂ "Enhance API security +/// with JWT auth middleware"), so genuine near-duplicates still merge, while two +/// large sets that share only a couple of incidental tokens score low and stay +/// apart. +fn tokens_overlap(a: &[String], b: &[String]) -> bool { + if a.is_empty() || b.is_empty() { + return false; + } + let shared = a.iter().filter(|t| b.contains(t)).count(); + let smaller = a.len().min(b.len()); + // A single-token set scores a perfect 1.0 against any longer title that + // merely contains that token, so a generic summary like "API" / "Fix API" + // would cluster with every unrelated "Add API auth", "Update API billing", + // etc. Require at least TWO shared tokens whenever the smaller set has only + // one token, so singletons need real signal before merging. + if smaller <= 1 { + return shared >= 2; + } + (shared as f64 / smaller as f64) >= CLUSTER_SIMILARITY_THRESHOLD +} + +/// Deterministically cluster summarized entries by title similarity and return +/// `(session_id, group_label)` for every entry. Greedy O(n²) clustering — n is +/// small (one report's worth of sessions). Each cluster is labeled with its most +/// frequent original title, tie-broken by shortest, so the label is a real +/// human-readable title rather than a synthetic key. +fn cluster_titles(entries: &[&WikiEntry]) -> Vec<(String, String)> { + struct Cluster { + // Token sets of EVERY member, kept separately rather than unioned into a + // single signature. A candidate joins if it overlaps ANY member, which + // preserves transitive grouping of genuine near-duplicates WITHOUT the + // union ballooning into a catch-all that absorbs unrelated titles. + member_tokens: Vec>, + // Exact normalized title shared by a token-empty cluster (all-stopword + // titles). `None` for tokened clusters. + empty_key: Option, + members: Vec, + } + + // Precompute significant tokens + normalized title once per entry. + let prepared: Vec<(usize, Vec, String)> = entries + .iter() + .enumerate() + .map(|(i, e)| { + let raw = e.title.as_deref().unwrap_or(""); + (i, significant_tokens(raw), normalized_title(raw)) + }) + .collect(); + + let mut clusters: Vec = Vec::new(); + for (idx, tokens, norm) in &prepared { + // Find the first existing cluster this entry overlaps strongly with. + let mut placed = false; + for cluster in clusters.iter_mut() { + let matches = if tokens.is_empty() { + // Token-empty titles (all stopwords) carry no signal to cluster + // on, so they only merge with an identical normalized title. + // This stops every generic/stopword-only title from collapsing + // into one arbitrary blob. + cluster.empty_key.as_deref() == Some(norm.as_str()) + } else { + // Tokened entries never join an empty cluster; they match if they + // overlap ANY existing member of the cluster. + cluster.empty_key.is_none() + && cluster + .member_tokens + .iter() + .any(|m| tokens_overlap(tokens, m)) + }; + if matches { + cluster.members.push(*idx); + cluster.member_tokens.push(tokens.clone()); + placed = true; + break; + } + } + if !placed { + clusters.push(Cluster { + member_tokens: vec![tokens.clone()], + empty_key: if tokens.is_empty() { + Some(norm.clone()) + } else { + None + }, + members: vec![*idx], + }); + } + } + + // Consolidation pass: the single greedy pass above is order-dependent — an + // entry compared before its eventual neighbor was seen can land in its own + // cluster even though it overlaps a member of another cluster. Repeatedly + // merge any two clusters that have a pair of overlapping members (or, for + // token-empty clusters, an identical normalized title) until a fixpoint, + // making the final grouping independent of input order. n is small, so the + // O(n²)-per-round loop is cheap. Crucially, merging only happens on a real + // member-to-member overlap, so it cannot chain unrelated clusters together. + loop { + let mut merged_any = false; + 'outer: for i in 0..clusters.len() { + for j in (i + 1)..clusters.len() { + let overlap = match (&clusters[i].empty_key, &clusters[j].empty_key) { + // Token-empty clusters merge only with an identical title. + (Some(ki), Some(kj)) => ki == kj, + // A token-empty cluster never merges with a tokened one. + (Some(_), None) | (None, Some(_)) => false, + // Tokened clusters merge if any member pair overlaps. + (None, None) => clusters[i].member_tokens.iter().any(|mi| { + clusters[j] + .member_tokens + .iter() + .any(|mj| tokens_overlap(mi, mj)) + }), + }; + if overlap { + let other = clusters.remove(j); + clusters[i].members.extend(other.members); + clusters[i].member_tokens.extend(other.member_tokens); + merged_any = true; + break 'outer; + } + } + } + if !merged_any { + break; + } + } + + let mut assignments = Vec::new(); + for cluster in &clusters { + let label = cluster_label(entries, &cluster.members); + for &idx in &cluster.members { + assignments.push((entries[idx].session_id.clone(), label.clone())); + } + } + assignments +} + +/// Pick a human-readable label for a cluster: the most frequent original title, +/// tie-broken by shortest (char count), then lexicographically for full +/// determinism. +fn cluster_label(entries: &[&WikiEntry], members: &[usize]) -> String { + let mut counts: HashMap<&str, usize> = HashMap::new(); + for &idx in members { + let title = entries[idx] + .title + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .unwrap_or("(unsummarized)"); + *counts.entry(title).or_insert(0) += 1; + } + counts + .into_iter() + .max_by(|(at, ac), (bt, bc)| { + ac.cmp(bc) + // Higher frequency wins; on a tie prefer the SHORTER title, then + // lexicographically smaller, so the label is stable run-to-run. + .then_with(|| bt.chars().count().cmp(&at.chars().count())) + .then_with(|| bt.cmp(at)) + }) + .map(|(title, _)| title.to_string()) + .unwrap_or_else(|| "(unsummarized)".to_string()) +} + +fn run_apple_fm_summarizer(payloads: &[serde_json::Value]) -> Result> { + // Build typed inputs from the JSON payloads. + let inputs: Vec = payloads + .iter() + .map(|p| apple_fm::SessionInput { + session_id: p["session_id"].as_str().unwrap_or_default().to_string(), + client: p["client"].as_str().unwrap_or_default().to_string(), + workspace: p["workspace"].as_str().unwrap_or_default().to_string(), + first_user_message: p["first_user_message"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + models_used: p["models_used"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(), + total_tokens: p["total_tokens"].as_i64().unwrap_or(0), + duration_minutes: p["duration_minutes"].as_i64().unwrap_or(0), + message_count: p["message_count"].as_i64().unwrap_or(0), + }) + .collect(); + + // `summarize` returns `Some` only when Apple Intelligence is available and + // the feature is enabled on macOS. In every other case (unavailable, + // feature-off, or non-macOS) it returns `None` and we apply the Rust + // heuristic to all sessions. apple-fm therefore stays the default backend + // and degrades gracefully cross-platform — it never errors out the report. + let summaries: Vec = match apple_fm::summarize(&inputs) { + Some(v) => v, + None => inputs.iter().map(apple_fm::heuristic_classify).collect(), + }; + + // Provenance is carried PER summary (`s.fm_version`): even when Apple FM is + // available, an individual generation that fails/times out is backfilled with + // the heuristic and must not be recorded as `apple-fm-on-device`. + let results = summaries + .into_iter() + .map(|s| { + serde_json::json!({ + "session_id": s.session_id, + "title": s.title, + "task_category": s.task_category, + "description": s.description, + "complexity": s.complexity, + "fm_version": s.fm_version, + }) + }) + .collect(); + + Ok(results) +} + +const SUMMARIZER_SYSTEM_PROMPT: &str = r#"You are a coding session classifier. Given metadata about an AI coding session, produce a structured summary. + +Rules: +- title: 3-8 word description of what was done (imperative mood, e.g. "Add JWT auth middleware") +- task_category: exactly one of: feature, bugfix, refactor, research, debug, review, docs, config, other +- description: 1-2 sentences explaining what happened in the session +- complexity: exactly one of: trivial, moderate, complex + +Respond ONLY with a JSON array where each element has: session_id, title, task_category, description, complexity."#; + +fn build_cli_prompt(payloads: &[serde_json::Value]) -> String { + let mut parts = Vec::new(); + parts.push("Classify these coding sessions:\n".to_string()); + for (i, p) in payloads.iter().enumerate() { + parts.push(format!( + "Session {} (id: {}):\n Workspace: {}\n Client: {}\n Models: {}\n Tokens: {}\n Duration: {} min\n Messages: {}\n First message: {}\n", + i + 1, + p["session_id"].as_str().unwrap_or("?"), + p["workspace"].as_str().unwrap_or("?"), + p["client"].as_str().unwrap_or("?"), + p["models_used"], + p["total_tokens"], + p["duration_minutes"], + p["message_count"], + p["first_user_message"].as_str().unwrap_or("(none)").chars().take(200).collect::(), + )); + } + parts.push("Respond with a JSON array.".to_string()); + parts.join("\n") +} + +fn run_cli_summarizer( + backend: &str, + payloads: &[serde_json::Value], +) -> Result> { + let prompt = build_cli_prompt(payloads); + + let cmd = match backend { + "claude" => { + let mut c = Command::new("claude"); + c.args(["-p", "--output-format", "text"]).arg(format!( + "System: {}\n\n{}", + SUMMARIZER_SYSTEM_PROMPT, prompt + )); + c + } + "codex" => { + let mut c = Command::new("codex"); + c.args(["exec"]) + .arg(format!("{}\n\n{}", SUMMARIZER_SYSTEM_PROMPT, prompt)); + c + } + "gemini" => { + let mut c = Command::new("gemini"); + c.args(["-p"]) + .arg(format!("{}\n\n{}", SUMMARIZER_SYSTEM_PROMPT, prompt)); + c + } + "kiro" => { + let mut c = Command::new("kiro-cli"); + c.args(["chat", "--no-interactive"]) + .arg(format!("{}\n\n{}", SUMMARIZER_SYSTEM_PROMPT, prompt)); + c + } + _ => return Ok(Vec::new()), + }; + + // A timed-out (or un-spawnable) backend must degrade gracefully: log it and + // return no summaries so the caller continues, matching the non-zero-exit + // path below. + let output = match run_command_with_timeout(cmd, BACKEND_TIMEOUT, None) { + Ok(output) => output, + Err(e) => { + eprintln!(" {} {} summarizer failed: {}", "⚠".yellow(), backend, e); + return Ok(Vec::new()); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!( + " {} {} summarizer failed: {}", + "⚠".yellow(), + backend, + stderr.trim() + ); + return Ok(Vec::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let json_str = extract_json_array(&stdout); + + match serde_json::from_str::>(json_str) { + Ok(results) => Ok(results), + Err(e) => { + eprintln!( + " {} Failed to parse {} response: {}", + "⚠".yellow(), + backend, + e + ); + Ok(Vec::new()) + } + } +} + +/// Upper bound on how long any LLM summarizer subprocess may run before we +/// kill it. Deliberately generous (5 min) so legitimate batched LLM calls +/// never trip it, but it bounds a true hang (auth prompt, network stall) so +/// `tokscale report` can never block forever. +const BACKEND_TIMEOUT: Duration = Duration::from_secs(300); + +/// Spawn `cmd`, optionally write `stdin_bytes` to its stdin, and wait up to +/// `timeout` for it to finish. +/// +/// Mirrors the pure-std spawn + reader-thread + `try_wait()` deadline + kill +/// approach used by `run_capture_command` in `main.rs` (no extra dependency). +/// Both stdout and stderr are drained on dedicated threads so a chatty backend +/// cannot deadlock on a full pipe buffer while we poll for exit. +/// +/// On timeout the child is killed and an `io::Error` of kind `TimedOut` is +/// returned, which callers treat as a recoverable "skip this backend" signal. +fn run_command_with_timeout( + mut cmd: Command, + timeout: Duration, + stdin_bytes: Option<&[u8]>, +) -> std::io::Result { + use std::io::Read; + use std::thread; + use std::time::Instant; + + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + if stdin_bytes.is_some() { + cmd.stdin(Stdio::piped()); + } + + let mut child = cmd.spawn()?; + + // Write to stdin (if requested) before draining output, then drop the + // handle so the child sees EOF. + if let Some(bytes) = stdin_bytes { + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(bytes)?; + } + } + + let mut stdout = child + .stdout + .take() + .ok_or_else(|| std::io::Error::other("failed to capture subprocess stdout"))?; + let mut stderr = child + .stderr + .take() + .ok_or_else(|| std::io::Error::other("failed to capture subprocess stderr"))?; + + let stdout_handle = thread::spawn(move || -> std::io::Result> { + let mut buf = Vec::new(); + stdout.read_to_end(&mut buf)?; + Ok(buf) + }); + let stderr_handle = thread::spawn(move || -> std::io::Result> { + let mut buf = Vec::new(); + stderr.read_to_end(&mut buf)?; + Ok(buf) + }); + + let deadline = Instant::now() + timeout; + let status = loop { + if let Some(status) = child.try_wait()? { + break status; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "summarizer backend timed out", + )); + } + thread::sleep(Duration::from_millis(25)); + }; + + let stdout = stdout_handle + .join() + .map_err(|_| std::io::Error::other("subprocess stdout reader thread panicked"))??; + let stderr = stderr_handle + .join() + .map_err(|_| std::io::Error::other("subprocess stderr reader thread panicked"))??; + + Ok(Output { + status, + stdout, + stderr, + }) +} + +fn extract_json_array(text: &str) -> &str { + if let Some(start) = text.find('[') { + if let Some(end) = text.rfind(']') { + return &text[start..=end]; + } + } + text +} + +fn print_report_table( + entries: &[WikiEntry], + _db: &WikiDb, + is_multi_day: bool, + full: bool, +) -> Result<()> { + if entries.is_empty() { + println!("No sessions found for the given filters."); + return Ok(()); + } + + let total_cost: f64 = entries.iter().map(|e| e.total_cost).sum(); + let total_tokens: i64 = entries + .iter() + .map(|e| e.total_input_tokens.saturating_add(e.total_output_tokens)) + .fold(0i64, i64::saturating_add); + let total_sessions = entries.len(); + let summarized = entries.iter().filter(|e| e.title.is_some()).count(); + + println!(); + println!( + " {} sessions | {} summarized | ${:.2} total | {} tokens", + total_sessions.to_string().cyan(), + summarized.to_string().green(), + total_cost, + format_tokens(total_tokens).yellow(), + ); + println!(); + + let mut by_model: HashMap<&str, (f64, i64, usize)> = HashMap::new(); + for entry in entries { + if entry.models_used.is_empty() { + continue; + } + for model in &entry.models_used { + let agg = by_model.entry(model.as_str()).or_insert((0.0, 0, 0)); + agg.0 += entry.total_cost / entry.models_used.len() as f64; + agg.1 = agg.1.saturating_add( + entry + .total_input_tokens + .saturating_add(entry.total_output_tokens) + / entry.models_used.len() as i64, + ); + agg.2 += 1; + } + } + + let mut models: Vec<_> = by_model.iter().collect(); + models.sort_by(|a, b| b.1 .0.total_cmp(&a.1 .0)); + + println!( + " {:<30} {:>8} {:>12} {:>8}", + "Model", "Sessions", "Tokens", "Cost" + ); + println!(" {}", "─".repeat(62)); + for (model, (cost, tokens, count)) in &models { + println!( + " {:<30} {:>8} {:>12} {:>8}", + model, + count, + format_tokens(*tokens), + format!("${:.2}", cost), + ); + } + println!(" {}", "─".repeat(62)); + println!( + " {:<30} {:>8} {:>12} {:>8}", + "TOTAL", + total_sessions, + format_tokens(total_tokens), + format!("${:.2}", total_cost), + ); + println!(); + + let mut by_group: HashMap<&str, (f64, i64, usize, Vec<&str>)> = HashMap::new(); + for entry in entries { + let group = entry + .task_group + .as_deref() + .unwrap_or(entry.title.as_deref().unwrap_or("(unsummarized)")); + let title = entry.title.as_deref().unwrap_or("(unsummarized)"); + let agg = by_group.entry(group).or_insert((0.0, 0, 0, Vec::new())); + agg.0 += entry.total_cost; + agg.1 = agg.1.saturating_add( + entry + .total_input_tokens + .saturating_add(entry.total_output_tokens), + ); + agg.2 += 1; + if !agg.3.contains(&title) { + agg.3.push(title); + } + } + + let mut groups: Vec<_> = by_group.iter().collect(); + groups.sort_by(|a, b| b.1 .0.total_cmp(&a.1 .0)); + + println!( + " {:<40} {:>5} {:>10} {:>8}", + "Task Group", "Sess", "Tokens", "Cost" + ); + println!(" {}", "─".repeat(67)); + for (group, (cost, tokens, count, titles)) in groups.iter().take(15) { + let display_group: String = if group.chars().count() > 40 { + format!("{}…", group.chars().take(39).collect::()) + } else { + group.to_string() + }; + println!( + " {:<40} {:>5} {:>10} {:>8}", + display_group.bold(), + count, + format_tokens(*tokens), + format!("${:.2}", cost), + ); + if *count > 1 { + for t in titles.iter().take(3) { + let display_t: String = if t.chars().count() > 38 { + t.chars().take(38).collect::() + } else { + t.to_string() + }; + println!(" {}", display_t.dimmed()); + } + if titles.len() > 3 { + println!(" … +{} more", titles.len() - 3); + } + } + } + if groups.len() > 15 { + let rest_count: usize = groups.iter().skip(15).map(|(_, v)| v.2).sum(); + let rest_cost: f64 = groups.iter().skip(15).map(|(_, v)| v.0).sum(); + let rest_tokens: i64 = groups.iter().skip(15).map(|(_, v)| v.1).sum(); + println!( + " {:<40} {:>5} {:>10} {:>8}", + format!("… +{} more", groups.len() - 15), + rest_count, + format_tokens(rest_tokens), + format!("${:.2}", rest_cost), + ); + } + println!(" {}", "─".repeat(67)); + println!(); + + if is_multi_day { + print_daily_breakdown(entries, full); + } else { + print_session_list(entries, full); + } + + Ok(()) +} + +fn print_daily_breakdown(entries: &[WikiEntry], full: bool) { + use std::collections::BTreeMap; + + let mut by_date: BTreeMap)> = BTreeMap::new(); + for entry in entries { + let date_key = Local + .timestamp_millis_opt(entry.created_at) + .single() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let agg = by_date.entry(date_key).or_insert((0.0, 0, 0, Vec::new())); + agg.0 += entry.total_cost; + agg.1 = agg.1.saturating_add( + entry + .total_input_tokens + .saturating_add(entry.total_output_tokens), + ); + agg.2 += 1; + agg.3.push(entry); + } + + let mut dates: Vec<_> = by_date.iter().collect(); + dates.sort_by(|a, b| b.0.cmp(a.0)); + + println!(" Daily breakdown:"); + println!(" {}", "─".repeat(72)); + for (date, (cost, tokens, count, sessions)) in &dates { + println!( + " {} {:>3} sessions {:>10} tokens {:>8}", + date.cyan(), + count, + format_tokens(*tokens), + format!("${:.2}", cost), + ); + let daily_limit = if full { sessions.len() } else { 5 }; + for s in sessions.iter().take(daily_limit) { + let title = s.title.as_deref().unwrap_or("(pending)"); + let model = s.models_used.first().map(|m| m.as_str()).unwrap_or("-"); + let display_title: String = if title.chars().count() > 40 { + title.chars().take(40).collect::() + } else { + title.to_string() + }; + println!( + " {:>6} {:<18} {}", + format!("${:.2}", s.total_cost), + model.dimmed(), + display_title, + ); + } + if sessions.len() > 5 { + println!(" … +{} more sessions", sessions.len() - 5); + } + } + println!(); +} + +fn print_session_list(entries: &[WikiEntry], full: bool) { + let list_limit = if full { entries.len() } else { 10 }; + let recent: Vec<&WikiEntry> = entries.iter().take(list_limit).collect(); + if !recent.is_empty() { + println!(" Sessions:"); + println!(" {}", "─".repeat(80)); + for entry in recent { + let date = Local + .timestamp_millis_opt(entry.created_at) + .single() + .map(|dt| dt.format("%H:%M").to_string()) + .unwrap_or_else(|| "??:??".to_string()); + + let title = entry.title.as_deref().unwrap_or("(pending summarization)"); + let model = entry.models_used.first().map(|s| s.as_str()).unwrap_or("-"); + let cost = format!("${:.2}", entry.total_cost); + + println!( + " {} {:>6} {:<20} {}", + date.dimmed(), + cost, + model.dimmed(), + title, + ); + } + if !full && entries.len() > 10 { + println!(" … +{} more sessions", entries.len() - 10); + } + println!(); + } +} + +/// Maps every locally-discovered session to the on-disk file(s) its content can +/// be extracted from, so the summarizer payload carries the real first user +/// message instead of metadata only. +/// +/// File-keyed clients (claude, codex, gemini) live as one transcript file per +/// session, indexed here by `(client, session_id)`. The client is part of the +/// key so cross-client `session_id` collisions can't feed one client's file to +/// another client's extractor. OpenCode sessions live as rows inside a shared +/// SQLite database, so every opencode database is kept as a candidate and the +/// extractor selects the matching session internally. +#[derive(Default)] +struct SessionPathIndex { + by_client_session: HashMap<(String, String), Vec>, + opencode_dbs: Vec, +} + +impl SessionPathIndex { + /// Candidate file(s) to feed the dispatcher for `(client, session_id)`. + fn candidates_for(&self, client: &str, session_id: &str) -> Vec { + if client == "opencode" { + return self.opencode_dbs.clone(); + } + self.by_client_session + .get(&(client.to_string(), session_id.to_string())) + .cloned() + .unwrap_or_default() + } +} + +/// Scan local client data once and index every session file by +/// `(client, session_id)` plus the OpenCode databases, so per-session content +/// extraction never has to re-walk the filesystem. Scanning is best-effort: any +/// client the summarizer can't extract simply yields no candidates and falls +/// back to metadata-only. +/// +/// The `session_id` used as the key must match how the wiki populates its +/// entries. For most clients that is the file stem, but Gemini transcripts derive +/// the id from the in-file `sessionId`/`session_id` field, so they are keyed by +/// the parsed id (with the stem kept as a fallback alias). +fn build_session_path_index(opts: &ReportOptions) -> SessionPathIndex { + let home_dir = opts + .home_dir + .clone() + .or_else(|| std::env::var("HOME").ok()) + .unwrap_or_default(); + let use_env_roots = opts.home_dir.is_none(); + + let scan = tokscale_core::scanner::scan_all_clients_with_scanner_settings( + &home_dir, + &[], + use_env_roots, + &opts.scanner_settings, + ); + + let mut by_client_session: HashMap<(String, String), Vec> = HashMap::new(); + for (client, path) in scan.all_files() { + let client_str = client.as_str().to_string(); + let mut session_ids: Vec = Vec::new(); + + if client == tokscale_core::ClientId::Gemini { + // Gemini's wiki session_id comes from inside the file, not the stem. + if let Some(id) = tokscale_core::sessions::gemini::gemini_session_id_for_file(&path) { + session_ids.push(id); + } + } + // Always keep the file stem as a key/alias so stem-keyed clients work and + // Gemini lookups still resolve if the wiki used the stem. + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + session_ids.push(stem.to_string()); + } + + session_ids.sort(); + session_ids.dedup(); + for session_id in session_ids { + by_client_session + .entry((client_str.clone(), session_id)) + .or_default() + .push(path.clone()); + } + } + + SessionPathIndex { + by_client_session, + opencode_dbs: scan.opencode_dbs.clone(), + } +} + +/// Resolve a session's real content by dispatching to the correct per-client +/// extractor over its on-disk file(s). Falls back to metadata-only when the +/// client is unsupported or no candidate file yields a first user message. +fn extract_content_for_session( + entry: &WikiEntry, + session_paths: &SessionPathIndex, +) -> SessionContent { + let candidates = session_paths.candidates_for(&entry.client, &entry.session_id); + if candidates.is_empty() { + return metadata_only_content(&entry.session_id, &entry.client); + } + extract_session_content(&entry.client, &entry.session_id, &candidates) +} + +fn parse_date_range(since: &Option, until: &Option) -> (Option, Option) { + // The `since`/`until` strings are local-calendar dates (e.g. produced by + // `build_date_filter`, which derives them from `chrono::Local::now()`), and + // session dates are bucketed in local time (see + // `sessions::timestamp_to_date`). Interpret the day boundaries in local time + // so filtering lines up with grouping and avoids off-by-a-day mismatches. + let since_ts = since + .as_ref() + .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) + .and_then(local_start_of_day_millis); + let until_ts = until + .as_ref() + .and_then(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) + .and_then(|d| d.succ_opt()) + .and_then(|next| local_start_of_day_millis(next).map(|ms| ms - 1)); + (since_ts, until_ts) +} + +/// Returns the Unix-millisecond timestamp for the start of `date` in the local +/// timezone. +/// +/// This is normally midnight (00:00:00), but in zones that spring forward at +/// local midnight (e.g. `America/Nuuk` on `2024-03-31`) that wall-clock time +/// does not exist. Rather than dropping the boundary (which would silently make +/// date filtering unbounded), we walk forward to the first representable instant +/// after the gap so the day boundary is preserved. +fn local_start_of_day_millis(date: chrono::NaiveDate) -> Option { + start_of_day_millis_with(date, |wall| Local.from_local_datetime(wall)) +} + +/// Core of [`local_start_of_day_millis`], parameterized over the timezone +/// resolver so the DST-gap handling can be exercised deterministically in tests. +/// +/// Starts at midnight and, when that wall-clock time is skipped (a spring-forward +/// gap), walks forward in 1-minute steps to the first representable instant. The +/// probe window covers a full day so even unusual offsets resolve rather than +/// silently dropping the boundary. +fn start_of_day_millis_with(date: chrono::NaiveDate, resolve: F) -> Option +where + F: Fn(&chrono::NaiveDateTime) -> chrono::LocalResult>, +{ + let mut wall = date.and_hms_opt(0, 0, 0)?; + for _ in 0..=(24 * 60) { + match resolve(&wall) { + chrono::LocalResult::Single(dt) | chrono::LocalResult::Ambiguous(dt, _) => { + return Some(dt.timestamp_millis()); + } + chrono::LocalResult::None => { + wall += chrono::Duration::minutes(1); + } + } + } + None +} + +/// Loads the canonical pricing dataset for cost attribution, preferring a fresh +/// fetch but falling back to any cached dataset so reports still work offline. +/// Returns `None` only when no pricing data is available at all. +fn load_pricing_service() -> Option> { + let fresh = tokio::runtime::Runtime::new() + .ok() + .and_then(|rt| rt.block_on(async { PricingService::get_or_init().await.ok() })); + fresh.or_else(|| PricingService::load_cached_any_age().map(std::sync::Arc::new)) +} + +/// Computes a message's cost using the canonical [`PricingService`], honoring +/// per-model rates and every billed token type (input/output/cache read/cache +/// write/reasoning). Returns 0.0 when no pricing dataset is available. +fn compute_msg_cost(msg: &ParsedMessage, pricing: Option<&PricingService>) -> f64 { + let Some(pricing) = pricing else { + return 0.0; + }; + pricing.calculate_cost_with_provider( + &msg.model_id, + Some(&msg.provider_id), + &TokenBreakdown { + input: msg.input, + output: msg.output, + cache_read: msg.cache_read, + cache_write: msg.cache_write, + reasoning: msg.reasoning, + }, + ) +} + +fn format_tokens(tokens: i64) -> String { + if tokens >= 1_000_000_000 { + format!("{:.1}B", tokens as f64 / 1_000_000_000.0) + } else if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + format!("{:.0}K", tokens as f64 / 1_000.0) + } else { + tokens.to_string() + } +} + +struct SessionAgg { + client: String, + workspace: Option, + workspace_label: Option, + created_at: i64, + last_active: i64, + total_input: i64, + total_output: i64, + total_cache_read: i64, + total_cost: f64, + models: HashMap, + message_count: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + use tokscale_core::pricing::{ModelPricing, PricingService}; + + fn test_pricing_service() -> PricingService { + let mut litellm = HashMap::new(); + litellm.insert( + "claude-haiku-4".to_string(), + ModelPricing { + input_cost_per_token: Some(0.000004), + output_cost_per_token: Some(0.000006), + cache_read_input_token_cost: Some(0.000001), + ..Default::default() + }, + ); + PricingService::new(litellm, HashMap::new()) + } + + fn parsed_message(model_id: &str) -> ParsedMessage { + ParsedMessage { + client: "claude".to_string(), + model_id: model_id.to_string(), + provider_id: "anthropic".to_string(), + session_id: "s1".to_string(), + workspace_key: None, + workspace_label: None, + timestamp: 0, + date: "2026-01-01".to_string(), + input: 1_000, + output: 500, + cache_read: 2_000, + cache_write: 0, + reasoning: 0, + duration_ms: None, + message_count: 1, + agent: None, + } + } + + #[test] + fn compute_msg_cost_matches_canonical_pricing_service() { + let pricing = test_pricing_service(); + let msg = parsed_message("claude-haiku-4"); + + let report_cost = compute_msg_cost(&msg, Some(&pricing)); + let canonical = pricing.calculate_cost_with_provider( + &msg.model_id, + Some(&msg.provider_id), + &TokenBreakdown { + input: msg.input, + output: msg.output, + cache_read: msg.cache_read, + cache_write: msg.cache_write, + reasoning: msg.reasoning, + }, + ); + + // The report must price exactly what PricingService yields — no + // hardcoded flat rates, no fuzzy matching. + assert_eq!(report_cost, canonical); + assert!( + canonical > 0.0, + "expected a positive cost for a known model" + ); + } + + #[test] + fn parse_date_range_buckets_in_local_time() { + use chrono::{Local, TimeZone}; + + // Pick an arbitrary calendar day. The exact day is irrelevant; what + // matters is that `parse_date_range` interprets the boundaries in the + // *local* timezone, matching how `sessions::timestamp_to_date` buckets + // each message (and how `build_date_filter` derives these strings from + // `chrono::Local::now()`). + let day = "2026-03-08"; + let (since, until) = parse_date_range(&Some(day.into()), &Some(day.into())); + + let expected_since = Local + .with_ymd_and_hms(2026, 3, 8, 0, 0, 0) + .single() + .map(|dt| dt.timestamp_millis()) + .expect("local midnight exists for this fixed date"); + // The window is inclusive of the whole local day: [00:00:00.000, + // next-day 00:00:00.000 - 1ms]. + let expected_until = Local + .with_ymd_and_hms(2026, 3, 9, 0, 0, 0) + .single() + .map(|dt| dt.timestamp_millis() - 1) + .expect("local midnight exists for this fixed date"); + + assert_eq!(since, Some(expected_since)); + assert_eq!(until, Some(expected_until)); + + // Regression guard: the previous implementation interpreted the day as + // UTC. Any machine running in a non-UTC zone would then see boundaries + // shifted by the offset. Confirm the local boundary differs from the + // UTC one whenever the local offset is non-zero, so this test actually + // exercises the fix on offset machines (and stays correct on UTC ones). + let utc_since = chrono::NaiveDate::from_ymd_opt(2026, 3, 8) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp_millis(); + let local_offset_secs = Local + .offset_from_utc_datetime( + &chrono::NaiveDate::from_ymd_opt(2026, 3, 8) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .local_minus_utc(); + if local_offset_secs != 0 { + assert_ne!( + since, + Some(utc_since), + "local-time bucketing must differ from UTC on offset machines" + ); + } else { + assert_eq!(since, Some(utc_since)); + } + } + + #[test] + fn start_of_day_preserves_boundary_across_dst_gap() { + use chrono::{Local, LocalResult, TimeZone}; + + // Simulate a zone that springs forward at local midnight (like + // `America/Nuuk` on 2024-03-31, where 00:00–00:59 do not exist). The + // resolver maps any wall-clock time before 01:00 to `None` (the gap) and + // resolves 01:00+ as a real instant. The first valid instant after the + // gap must be returned instead of dropping the boundary. + let date = chrono::NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(); + let resolve = |wall: &chrono::NaiveDateTime| -> LocalResult> { + if wall.time() < chrono::NaiveTime::from_hms_opt(1, 0, 0).unwrap() { + LocalResult::None + } else { + // Resolve against the machine's local zone for an arbitrary but + // representable instant; the value just needs to be `Single`. + Local.from_local_datetime(wall) + } + }; + + let result = start_of_day_millis_with(date, resolve); + let expected = Local + .from_local_datetime(&date.and_hms_opt(1, 0, 0).unwrap()) + .single() + .map(|dt| dt.timestamp_millis()); + + // Boundary must be preserved (not `None`) and equal to the first valid + // post-gap instant (01:00 local). + assert!( + result.is_some(), + "DST-gap midnight must not drop the date boundary (would make filtering unbounded)" + ); + assert_eq!(result, expected); + } + + #[test] + fn start_of_day_uses_midnight_when_representable() { + use chrono::{Local, TimeZone}; + + // Sanity: when midnight exists, it is used unchanged (no forward walk). + let date = chrono::NaiveDate::from_ymd_opt(2026, 6, 22).unwrap(); + let result = start_of_day_millis_with(date, |wall| Local.from_local_datetime(wall)); + let expected = Local + .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) + .single() + .map(|dt| dt.timestamp_millis()); + assert_eq!(result, expected); + } + + #[test] + fn compute_msg_cost_without_pricing_is_zero() { + let msg = parsed_message("claude-haiku-4"); + assert_eq!(compute_msg_cost(&msg, None), 0.0); + } + + fn titled_entry(session_id: &str, title: &str) -> WikiEntry { + WikiEntry { + session_id: session_id.to_string(), + client: "apple-fm".to_string(), + workspace: None, + workspace_label: None, + created_at: 0, + last_active: 0, + title: Some(title.to_string()), + task_category: None, + description: None, + complexity: None, + task_group: None, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read: 0, + total_cost: 0.0, + models_used: Vec::new(), + message_count: 0, + duration_minutes: 0, + summarized_at: None, + fm_version: None, + } + } + + #[test] + fn significant_tokens_normalizes_and_strips_stopwords() { + // Lowercase, punctuation/ellipsis stripped, generic verbs dropped, + // result sorted + deduped. + assert_eq!( + significant_tokens("Add JWT auth middleware…"), + vec!["auth", "jwt", "middleware"] + ); + assert_eq!( + significant_tokens("Enhance API Security"), + vec!["api", "security"] + ); + // Trailing "..." and mixed case collapse to the same key. + assert_eq!( + significant_tokens("Fix the API Security..."), + vec!["api", "security"] + ); + } + + #[test] + fn cluster_titles_merges_near_duplicates() { + let entries = [ + titled_entry("a", "Enhance API Security"), + titled_entry("b", "Enhance API security with JWT auth middleware"), + titled_entry("c", "Add JWT auth middleware"), + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + + let label_of = |sid: &str| { + assignments + .iter() + .find(|(s, _)| s == sid) + .map(|(_, l)| l.clone()) + .unwrap() + }; + + // a & b share "api" + "security" → same cluster. + assert_eq!(label_of("a"), label_of("b")); + // b & c share "auth" + "jwt" + "middleware" → all three collapse via b. + assert_eq!(label_of("b"), label_of("c")); + + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!(distinct.len(), 1, "all three should merge into one task"); + } + + #[test] + fn cluster_titles_keeps_unrelated_apart() { + let entries = [ + titled_entry("a", "Add JWT auth middleware"), + titled_entry("b", "Update database migration scripts"), + titled_entry("c", "Refactor pricing service cache"), + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!( + distinct.len(), + 3, + "unrelated titles must stay in separate groups" + ); + } + + #[test] + fn cluster_label_prefers_most_frequent_then_shortest() { + // Two identical long titles + one shorter variant: frequency wins, so the + // repeated long title is the label even though a shorter one exists. + let entries = [ + titled_entry("a", "Add JWT auth middleware"), + titled_entry("b", "Add JWT auth middleware"), + titled_entry("c", "JWT auth"), + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let label = &assignments[0].1; + assert_eq!(label, "Add JWT auth middleware"); + } + + #[test] + fn tokens_overlap_uses_ratio_not_absolute_count() { + // Two long, unrelated titles that incidentally share TWO tokens + // ("add" is a stopword, so the shared pair here is "service" + "api"). + // Under the old `shared >= 2` rule these merged; the overlap coefficient + // (2 / 5 = 0.4 < 0.6) correctly keeps them apart. + let a = significant_tokens("Add pricing service api cache layer"); + let b = significant_tokens("Add billing service api webhook handler"); + let shared: Vec<_> = a.iter().filter(|t| b.contains(t)).collect(); + assert_eq!( + shared.len(), + 2, + "fixture must share exactly two tokens to exercise the old rule" + ); + assert!( + !tokens_overlap(&a, &b), + "two shared tokens out of five must NOT merge under the ratio rule" + ); + + // A short title fully contained in a long one still merges (coeff 1.0). + let short = significant_tokens("pricing service"); + assert!(tokens_overlap(&short, &a)); + } + + #[test] + fn tokens_overlap_singleton_does_not_overcluster() { + // A title reducing to a SINGLE significant token must not merge with + // every unrelated longer title that happens to contain that token — + // the overlap coefficient alone would score 1.0 here. + let single = significant_tokens("Fix API"); // -> ["api"] + assert_eq!(single, vec!["api".to_string()]); + let auth = significant_tokens("Add API auth"); // -> ["api", "auth"] + let billing = significant_tokens("Update API billing"); // -> ["api", "billing"] + assert!( + !tokens_overlap(&single, &auth), + "single shared token must not merge a singleton title" + ); + assert!( + !tokens_overlap(&single, &billing), + "single shared token must not merge a singleton title" + ); + // Two unrelated singletons that share their one token must also stay apart. + let other_single = significant_tokens("API"); // -> ["api"] + assert!(!tokens_overlap(&single, &other_single)); + // Genuinely related multi-token titles still cluster. + let related_long = significant_tokens("Add API security with JWT auth middleware"); + let related_short = significant_tokens("Enhance API auth"); // -> ["api", "auth"] + assert!( + tokens_overlap(&related_short, &related_long), + "two shared tokens out of two must still merge" + ); + } + + #[test] + fn cluster_titles_does_not_overcluster_singletons() { + // "Fix API" (singleton "api") must NOT swallow unrelated API titles. + let entries = [ + titled_entry("a", "Fix API"), + titled_entry("b", "Add API auth"), + titled_entry("c", "Update API billing"), + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!( + distinct.len(), + 3, + "a singleton-token title must not cluster with unrelated longer titles" + ); + } + + #[test] + fn significant_tokens_normalizes_nfc_nfd_equivalents() { + // Precomposed "café" (NFC, U+00E9) and decomposed "café" (NFD, + // "e" + U+0301 combining acute) are canonically equivalent and must + // tokenize identically once normalized to NFC before stripping marks. + let nfc = "Caf\u{00e9} module"; // café + let nfd = "Cafe\u{0301} module"; // cafe + combining acute + assert_ne!(nfc, nfd, "fixture must use distinct byte sequences"); + assert_eq!(significant_tokens(nfc), significant_tokens(nfd)); + assert!(significant_tokens(nfc).contains(&"café".to_string())); + } + + #[test] + fn cluster_titles_merges_nfc_nfd_equivalents() { + let entries = [ + titled_entry("a", "Refactor Caf\u{00e9} Strat\u{00e9}gie"), // NFC + titled_entry("b", "Refactor Cafe\u{0301} Strate\u{0301}gie"), // NFD + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!( + distinct.len(), + 1, + "canonically-equivalent titles must merge regardless of NFC/NFD form" + ); + } + + #[test] + fn cluster_titles_does_not_transitively_absorb_unrelated() { + // Chain of titles where each adjacent pair shares two incidental tokens + // but the ends are unrelated. The old union-growing signature plus the + // `shared >= 2` rule made all of these collapse into one blob. With the + // ratio rule and member-based matching they stay apart. + let entries = [ + titled_entry("a", "Add pricing service api cache"), + titled_entry("b", "Add billing service api webhook"), + titled_entry("c", "Add billing report export csv"), + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!( + distinct.len(), + 3, + "incidental two-token overlaps must not chain unrelated titles into one cluster" + ); + } + + #[test] + fn cluster_titles_separates_distinct_stopword_only_titles() { + // Titles made entirely of stopwords/generic verbs reduce to no + // significant tokens. The old code lumped ALL of them into one arbitrary + // group; now each distinct normalized title is its own singleton while + // identical ones still collapse. + let entries = [ + titled_entry("a", "Fix and update"), + titled_entry("b", "Refactor and improve"), + titled_entry("c", "Fix and update"), + ]; + // Sanity: these really are token-empty so we exercise the empty path. + assert!(significant_tokens("Fix and update").is_empty()); + assert!(significant_tokens("Refactor and improve").is_empty()); + + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let label_of = |sid: &str| { + assignments + .iter() + .find(|(s, _)| s == sid) + .map(|(_, l)| l.clone()) + .unwrap() + }; + // Identical stopword-only titles merge; distinct ones do not. + assert_eq!(label_of("a"), label_of("c")); + assert_ne!(label_of("a"), label_of("b")); + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!(distinct.len(), 2); + } + + #[test] + fn significant_tokens_folds_non_ascii_case() { + // Full Unicode lowercasing: "Café" and "café" must produce the same + // token. to_ascii_lowercase left the accented capital untouched, so the + // two titles would have clustered apart. + assert_eq!( + significant_tokens("Café Münchën Stratégie"), + significant_tokens("café münchën stratégie") + ); + assert!(significant_tokens("Café").contains(&"café".to_string())); + assert_eq!( + significant_tokens("İstanbul API"), + significant_tokens("i\u{307}stanbul api") + ); + assert!(significant_tokens("İstanbul").contains(&"istanbul".to_string())); + } + + #[test] + fn cluster_titles_merges_non_ascii_case_variants() { + let entries = [ + titled_entry("a", "Refactor Café Stratégie module"), + titled_entry("b", "Refactor café stratégie module"), + ]; + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!(distinct.len(), 1, "case-only differences must merge"); + } + + #[test] + fn cluster_titles_groups_exact_duplicates() { + // The degenerate case from the report: many identical titles must + // collapse into exactly one group. + let entries: Vec = (0..51) + .map(|i| titled_entry(&format!("s{i}"), "Add JWT auth middleware")) + .collect(); + let refs: Vec<&WikiEntry> = entries.iter().collect(); + let assignments = cluster_titles(&refs); + let distinct: std::collections::HashSet<_> = + assignments.iter().map(|(_, l)| l.clone()).collect(); + assert_eq!(distinct.len(), 1); + assert_eq!(assignments.len(), 51); + } + + fn entry_for(session_id: &str, client: &str) -> WikiEntry { + let mut e = titled_entry(session_id, "ignored"); + e.client = client.to_string(); + e.title = None; + e + } + + #[test] + fn extract_content_for_session_reads_real_claude_first_message() { + // A claudecode transcript on disk, keyed by file stem == session_id. + let dir = tempfile::tempdir().unwrap(); + let session_id = "sess-claude-1"; + let path = dir.path().join(format!("{session_id}.jsonl")); + std::fs::write( + &path, + r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Fix the login bug"}]}} +"#, + ) + .unwrap(); + + let mut by_client_session: HashMap<(String, String), Vec> = HashMap::new(); + by_client_session.insert(("claude".to_string(), session_id.to_string()), vec![path]); + let index = SessionPathIndex { + by_client_session, + opencode_dbs: Vec::new(), + }; + + let entry = entry_for(session_id, "claude"); + let content = extract_content_for_session(&entry, &index); + + // The dispatcher must have reached the real claudecode extractor and + // surfaced the actual first user message — not metadata-only. + assert_eq!( + content.first_user_message.as_deref(), + Some("Fix the login bug") + ); + assert_eq!(content.client, "claude"); + } + + #[test] + fn extract_content_for_session_unknown_client_falls_back_to_metadata_only() { + // An unsupported client has no dedicated extractor: must degrade to + // metadata-only (None) without error or panic, even if a stray file + // happens to share the session id. + let dir = tempfile::tempdir().unwrap(); + let session_id = "sess-unknown-1"; + let path = dir.path().join(format!("{session_id}.jsonl")); + std::fs::write(&path, "garbage\n").unwrap(); + + let mut by_client_session: HashMap<(String, String), Vec> = HashMap::new(); + by_client_session.insert( + ( + "some-unsupported-client".to_string(), + session_id.to_string(), + ), + vec![path], + ); + let index = SessionPathIndex { + by_client_session, + opencode_dbs: Vec::new(), + }; + + let entry = entry_for(session_id, "some-unsupported-client"); + let content = extract_content_for_session(&entry, &index); + + assert!(content.first_user_message.is_none()); + assert_eq!(content.client, "some-unsupported-client"); + } + + #[test] + fn extract_content_for_session_missing_file_falls_back_to_metadata_only() { + // Supported client but no candidate file on disk: never panic, return + // metadata-only. + let index = SessionPathIndex::default(); + let entry = entry_for("does-not-exist", "claude"); + let content = extract_content_for_session(&entry, &index); + assert!(content.first_user_message.is_none()); + assert_eq!(content.client, "claude"); + } + + #[test] + fn session_path_index_isolates_clients_with_same_session_id() { + // Two different clients share a session_id. Keying by (client, id) must + // route each lookup to that client's own file, never the other's. + let dir = tempfile::tempdir().unwrap(); + let claude_path = dir.path().join("claude.jsonl"); + let codex_path = dir.path().join("codex.jsonl"); + std::fs::write(&claude_path, "claude-bytes").unwrap(); + std::fs::write(&codex_path, "codex-bytes").unwrap(); + + let mut by_client_session: HashMap<(String, String), Vec> = HashMap::new(); + by_client_session.insert( + ("claude".to_string(), "shared".to_string()), + vec![claude_path.clone()], + ); + by_client_session.insert( + ("codex".to_string(), "shared".to_string()), + vec![codex_path.clone()], + ); + let index = SessionPathIndex { + by_client_session, + opencode_dbs: Vec::new(), + }; + + assert_eq!(index.candidates_for("claude", "shared"), vec![claude_path]); + assert_eq!(index.candidates_for("codex", "shared"), vec![codex_path]); + // Lookup for a client without a matching key returns nothing. + assert!(index.candidates_for("gemini", "shared").is_empty()); + } + + #[test] + fn build_session_path_index_keys_gemini_by_inner_session_id() { + // A Gemini chat recording's wiki session_id is the in-file `sessionId`, + // not the filename stem. The index must key by that inner id so the + // summarizer lookup resolves and surfaces the real first prompt. + let home = tempfile::tempdir().unwrap(); + let chats = home + .path() + .join(".gemini") + .join("tmp") + .join("projhash") + .join("chats"); + std::fs::create_dir_all(&chats).unwrap(); + let inner_id = "b8d9ab56-e7da-4dca-abc1-eb61158bed4f"; + let file = chats.join("session-2026-06-08T19-53-b8d9ab56.json"); + std::fs::write( + &file, + format!( + r#"{{"sessionId":"{inner_id}","messages":[{{"type":"user","content":"Hello Gemini"}}]}}"# + ), + ) + .unwrap(); + + let opts = ReportOptions { + json: false, + since: None, + until: None, + workspace: None, + client: None, + no_summarize: false, + summarizer: String::new(), + rebuild: false, + home_dir: Some(home.path().to_string_lossy().into_owned()), + scanner_settings: Default::default(), + today: false, + week: false, + month: false, + full: false, + }; + let index = build_session_path_index(&opts); + + // Lookup by the wiki session_id (inner id) must find the file. + let candidates = index.candidates_for("gemini", inner_id); + assert!( + candidates.iter().any(|p| p == &file), + "expected gemini index keyed by inner sessionId, candidates={candidates:?}" + ); + + let entry = entry_for(inner_id, "gemini"); + let content = extract_content_for_session(&entry, &index); + assert_eq!(content.first_user_message.as_deref(), Some("Hello Gemini")); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/amp.rs b/crates/tokscale-cli/src/commands/usage/amp.rs index 8bd6e2d6b..9044bef1b 100644 --- a/crates/tokscale-cli/src/commands/usage/amp.rs +++ b/crates/tokscale-cli/src/commands/usage/amp.rs @@ -41,11 +41,13 @@ fn read_credentials() -> Result { /// Parse a dollar amount like "$4.50" or "$1,200.00" from text starting at the given prefix. fn parse_dollar_after(text: &str, prefix: &str) -> Option { let start = text.find(prefix)? + prefix.len(); - let rest = &text[start..]; + // `start` is a valid byte offset (end of a found substring), but slice it + // via `get` so arbitrary display_text can never panic on a bad boundary. + let rest = text.get(start..)?; let end = rest .find(|c: char| !c.is_ascii_digit() && c != '.' && c != ',') .unwrap_or(rest.len()); - let num_str = &rest[..end]; + let num_str = rest.get(..end)?; num_str.replace(',', "").parse().ok() } @@ -55,40 +57,48 @@ fn parse_display_text(text: &str) -> Vec { // Parse free tier: "$X/$Y remaining" // Look for pattern like "$4.50/$20.00 remaining" if let Some(slash_pos) = text.find("/$") { - if let Some(dollar_before) = text[..slash_pos].rfind('$') { - let before = &text[dollar_before + 1..slash_pos]; - if let Ok(remaining) = before.replace(',', "").parse::() { - // Find the total after /$ - let after = &text[slash_pos + 2..]; - if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) { - if let Ok(total) = after[..space_pos].replace(',', "").parse::() { - if total > 0.0 && total.is_finite() && remaining.is_finite() { - let used = (total - remaining).max(0.0); - let used_pct = if used.is_finite() { - (used / total * 100.0).clamp(0.0, 100.0) - } else { - 0.0 - }; - let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0); - let mut resets_at = None; - - // Estimate reset time from hourly replenish rate - if let Some(rate) = parse_dollar_after(text, "+$") { - if rate > 0.0 && used > 0.0 && rate.is_finite() { - let secs = (used / rate * 3600.0) as i64; - let resets = - chrono::Utc::now() + chrono::Duration::seconds(secs); - resets_at = Some(resets.to_rfc3339()); + if let Some(dollar_before) = text.get(..slash_pos).and_then(|s| s.rfind('$')) { + // All byte offsets below come from `find`/`rfind` on `text`, so they + // sit on char boundaries; `get` keeps it panic-free regardless. + if let Some(before) = text.get(dollar_before + 1..slash_pos) { + if let Ok(remaining) = before.replace(',', "").parse::() { + // Find the total after /$ + if let Some(after) = text.get(slash_pos + 2..) { + // `space_pos` comes from `after.find`, so `after[..space_pos]` + // is always on a char boundary. + if let Some(space_pos) = after.find(|c: char| c.is_ascii_whitespace()) { + if let Ok(total) = after[..space_pos].replace(',', "").parse::() { + if total > 0.0 && total.is_finite() && remaining.is_finite() { + let used = (total - remaining).max(0.0); + let used_pct = if used.is_finite() { + (used / total * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0); + let mut resets_at = None; + + // Estimate reset time from hourly replenish rate + if let Some(rate) = parse_dollar_after(text, "+$") { + if rate > 0.0 && used > 0.0 && rate.is_finite() { + let secs = (used / rate * 3600.0) as i64; + let resets = chrono::Utc::now() + + chrono::Duration::seconds(secs); + resets_at = Some(resets.to_rfc3339()); + } + } + + metrics.push(UsageMetric { + label: "Free".into(), + used_percent: used_pct, + remaining_percent: remaining_pct, + remaining_label: Some(format!( + "${remaining:.2}/${total:.2}" + )), + resets_at, + }); } } - - metrics.push(UsageMetric { - label: "Free".into(), - used_percent: used_pct, - remaining_percent: remaining_pct, - remaining_label: Some(format!("${remaining:.2}/${total:.2}")), - resets_at, - }); } } } @@ -164,13 +174,55 @@ pub fn fetch() -> Result { let display_text = body.result.and_then(|r| r.display_text).unwrap_or_default(); let metrics = parse_display_text(&display_text); + if metrics.is_empty() { + anyhow::bail!("Amp returned no parseable usage (display_text format may have changed)"); + } let plan = detect_plan(&metrics); Ok(UsageOutput { provider: "Amp".into(), + account: None, plan, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_free_tier_balance() { + let metrics = parse_display_text("$4.50/$20.00 remaining"); + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].label, "Free"); + // $4.50 remaining of $20.00 -> 77.5% used, 22.5% left. + assert!((metrics[0].used_percent - 77.5).abs() < 1e-9); + assert!((metrics[0].remaining_percent - 22.5).abs() < 1e-9); + } + + #[test] + fn empty_display_text_yields_no_metrics() { + // fetch() bails on an empty metrics set so fetch_all drops the provider + // instead of rendering a bare header row. + assert!(parse_display_text("").is_empty()); + assert!(parse_display_text("no dollar figures here").is_empty()); + } + + #[test] + fn multibyte_display_text_does_not_panic() { + // Byte offsets from find/rfind must stay on char boundaries; arbitrary + // UTF-8 around the markers must never panic. + let _ = parse_display_text("残高 $4.50/$20.00 残り 한국어"); + let _ = parse_display_text("Individual credits: $5.00 残り"); + let _ = parse_dollar_after("プレフィックス€$1.23é", "€$"); + let _ = parse_dollar_after("+$0.50円/hr", "+$"); + // Marker present but followed immediately by a multibyte char. + let _ = parse_dollar_after("/$é", "/$"); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/claude.rs b/crates/tokscale-cli/src/commands/usage/claude.rs index 773a69fca..040df686c 100644 --- a/crates/tokscale-cli/src/commands/usage/claude.rs +++ b/crates/tokscale-cli/src/commands/usage/claude.rs @@ -219,9 +219,13 @@ pub fn fetch() -> Result { Ok(UsageOutput { provider: "Claude".into(), + account: None, plan, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) }) } diff --git a/crates/tokscale-cli/src/commands/usage/codex.rs b/crates/tokscale-cli/src/commands/usage/codex.rs index 6b0b15f2c..9ec368781 100644 --- a/crates/tokscale-cli/src/commands/usage/codex.rs +++ b/crates/tokscale-cli/src/commands/usage/codex.rs @@ -1,22 +1,36 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use chrono::{TimeZone, Utc}; -use serde::Deserialize; +use fs2::FileExt; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; use super::helpers::capitalize; -use super::{UsageMetric, UsageOutput}; +use super::{ + UsageAccount, UsageCreditStatus, UsageFetchDiagnostic, UsageFetchDiagnosticKind, + UsageFetchDiagnosticSeverity, UsageFetchReport, UsageMetric, UsageOutput, UsageResetCredit, + UsageResetCredits, UsageSpendControl, +}; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct Auth { tokens: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct Tokens { + #[serde(skip_serializing_if = "Option::is_none")] access_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] refresh_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] account_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] id_token: Option, } @@ -26,6 +40,11 @@ struct Usage { email: Option, plan_type: Option, rate_limit: Option, + #[serde(default, deserialize_with = "deserialize_null_default_vec")] + additional_rate_limits: Vec, + rate_limit_reset_credits: Option, + credits: Option, + spend_control: Option, } #[derive(Debug, Deserialize)] @@ -38,10 +57,77 @@ struct RateLimit { #[derive(Debug, Deserialize)] #[serde(rename_all = "snake_case")] struct Window { - used_percent: Option, + used_percent: Option, + limit_window_seconds: Option, + #[serde(alias = "resets_at")] reset_at: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct AdditionalRateLimit { + metered_feature: Option, + limit_name: Option, + rate_limit: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct ResetCreditsSummary { + available_count: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct ResetCreditsResponse { + available_count: Option, + #[serde(default)] + credits: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct ResetCredit { + id: Option, + status: Option, + reset_type: Option, + expires_at: Option, + title: Option, + description: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct Credits { + balance: Option, + has_credits: Option, + unlimited: Option, + overage_limit_reached: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct SpendControl { + individual_limit: Option, + reached: Option, +} + +fn deserialize_null_default_vec<'de, D, T>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Ok(Option::>::deserialize(deserializer)?.unwrap_or_default()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RateLimitResetConsumeResult { + #[serde(default)] + pub code: String, + pub windows_reset: Option, +} + #[derive(Debug, Deserialize)] struct Refresh { access_token: Option, @@ -50,41 +136,282 @@ struct Refresh { expires_in: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexAccount { + tokens: Tokens, + #[serde(rename = "createdAt")] + created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + label: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CodexCredentialsStore { + version: i32, + #[serde(rename = "activeAccountId")] + active_account_id: String, + accounts: HashMap, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CodexAccountInfo { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(rename = "accountId", skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(rename = "createdAt")] + pub created_at: String, + #[serde(rename = "isActive")] + pub is_active: bool, +} + #[derive(Debug, Clone)] enum CredentialSource { - File(std::path::PathBuf), + File(PathBuf), Keychain, + Store(String), +} + +#[derive(Debug)] +enum CodexUsageError { + MissingCredentials, + NeedsAuth, + UnsupportedStoreVersion { version: i64, path: PathBuf }, +} + +impl fmt::Display for CodexUsageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingCredentials => { + write!(f, "No Codex credentials found. Run 'codex' to log in.") + } + Self::NeedsAuth => write!(f, "Codex credentials need authentication"), + Self::UnsupportedStoreVersion { version, path } => write!( + f, + "Unsupported Codex account store version {version} at {} (this tokscale supports version 1); refusing to modify it", + path.display() + ), + } + } +} + +impl std::error::Error for CodexUsageError {} + +fn has_codex_error(error: &anyhow::Error, predicate: impl Fn(&CodexUsageError) -> bool) -> bool { + error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(&predicate) + }) +} + +fn is_missing_credentials(error: &anyhow::Error) -> bool { + has_codex_error(error, |error| { + matches!(error, CodexUsageError::MissingCredentials) + }) +} + +fn is_needs_auth(error: &anyhow::Error) -> bool { + has_codex_error(error, |error| matches!(error, CodexUsageError::NeedsAuth)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CodexFetchIntent { + ReadOnly, + SaveCurrentLogin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StoreRepairPolicy { + InMemoryOnly, + Persist, +} + +fn codex_store_path() -> PathBuf { + crate::paths::get_config_dir().join("codex-credentials.json") +} + +#[cfg(test)] +fn codex_store_path_in_home(home_dir: &Path) -> PathBuf { + home_dir + .join(".config") + .join("tokscale") + .join("codex-credentials.json") } -fn read_credentials() -> Result<(Auth, CredentialSource)> { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let mut paths: Vec = Vec::new(); +#[derive(Debug, Clone)] +struct CodexAccountStore { + path: PathBuf, +} + +impl CodexAccountStore { + fn default() -> Self { + Self { + path: codex_store_path(), + } + } + + fn at_path(path: impl Into) -> Self { + Self { path: path.into() } + } + + #[cfg(test)] + fn in_home(home_dir: &Path) -> Self { + Self::at_path(codex_store_path_in_home(home_dir)) + } + + fn path(&self) -> &Path { + &self.path + } + + fn lock_path(&self) -> PathBuf { + self.path.with_extension("lock") + } + + fn with_lock(&self, action: impl FnOnce(&Self) -> Result) -> Result { + let lock_path = self.lock_path(); + if let Some(dir) = lock_path.parent() { + std::fs::create_dir_all(dir).with_context(|| { + format!("Failed to create Codex account lock dir {}", dir.display()) + })?; + } + let lock_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .with_context(|| { + format!("Failed to open Codex account lock {}", lock_path.display()) + })?; + lock_file.lock_exclusive().with_context(|| { + format!("Failed to lock Codex account store {}", self.path.display()) + })?; + + let result = action(self); + let unlock_result = lock_file.unlock().with_context(|| { + format!( + "Failed to unlock Codex account store {}", + self.path.display() + ) + }); + + match (result, unlock_result) { + (Ok(value), Ok(())) => Ok(value), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } + } + + fn read(&self) -> Option { + self.read_result().ok().flatten() + } + + fn read_result(&self) -> Result> { + self.read_unlocked(StoreRepairPolicy::InMemoryOnly) + } + + fn read_for_update(&self) -> Result> { + self.with_lock(|store| store.read_for_update_unlocked()) + } + + fn read_for_update_unlocked(&self) -> Result> { + self.read_unlocked(StoreRepairPolicy::Persist) + } + + fn read_unlocked( + &self, + repair_policy: StoreRepairPolicy, + ) -> Result> { + load_credentials_store_from_path_unlocked(self.path(), repair_policy) + } + + #[cfg(test)] + fn save(&self, store: &CodexCredentialsStore) -> Result<()> { + self.with_lock(|store_file| store_file.save_unlocked(store)) + } + + fn save_unlocked(&self, store: &CodexCredentialsStore) -> Result<()> { + let json = serde_json::to_string_pretty(store)?; + super::helpers::atomic_write_secret(self.path(), json.as_bytes()).with_context(|| { + format!( + "Failed to write Codex account store to {}", + self.path.display() + ) + }) + } + + fn update_existing( + &self, + missing_message: &'static str, + action: impl FnOnce(&mut CodexCredentialsStore) -> Result, + ) -> Result { + self.with_lock(|store_file| { + let mut store = store_file + .read_for_update_unlocked()? + .ok_or_else(|| anyhow::anyhow!(missing_message))?; + let result = action(&mut store)?; + store_file.save_unlocked(&store)?; + Ok(result) + }) + } +} + +fn current_auth_paths() -> Vec { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let mut paths = Vec::new(); - // CODEX_HOME takes precedence if let Ok(codex_home) = std::env::var("CODEX_HOME") { - paths.push(std::path::PathBuf::from(codex_home).join("auth.json")); + if !codex_home.trim().is_empty() { + paths.push(PathBuf::from(codex_home).join("auth.json")); + } } + paths.push(home.join(".config").join("codex").join("auth.json")); paths.push(home.join(".codex").join("auth.json")); + paths +} + +/// Where `switch` writes the codex CLI auth. Derived from +/// [`current_auth_paths`]: an explicit `CODEX_HOME` always wins (even if no +/// auth.json exists there yet); otherwise the first existing path, falling +/// back to the modern config location. +fn auth_write_path() -> Result { + let paths = current_auth_paths(); + let has_codex_home = std::env::var("CODEX_HOME") + .map(|home| !home.trim().is_empty()) + .unwrap_or(false); + + if !has_codex_home { + if let Some(existing) = paths.iter().find(|path| path.exists()) { + return Ok(existing.clone()); + } + } + + paths + .into_iter() + .next() + .context("Could not determine Codex auth path") +} - for p in &paths { +fn read_current_credentials() -> Result<(Auth, CredentialSource)> { + for p in current_auth_paths() { if p.exists() { - let content = std::fs::read_to_string(p)?; + let content = std::fs::read_to_string(&p)?; if let Ok(auth) = serde_json::from_str::(&content) { - // Only accept if tokens contains a usable access_token if auth .tokens .as_ref() .and_then(|t| t.access_token.as_ref()) .is_some() { - return Ok((auth, CredentialSource::File(p.clone()))); + return Ok((auth, CredentialSource::File(p))); } } } } - // macOS keychain fallback if let Ok(raw) = super::helpers::read_keychain("Codex Auth") { if let Ok(auth) = serde_json::from_str::(&raw) { if auth @@ -98,195 +425,2452 @@ fn read_credentials() -> Result<(Auth, CredentialSource)> { } } - anyhow::bail!("No Codex credentials found. Run 'codex' to log in.") + Err(CodexUsageError::MissingCredentials.into()) } -fn save_credentials( - path: &std::path::Path, - access_token: &str, - refresh_token: &str, - account_id: Option<&str>, - id_token: Option<&str>, -) { - let mut tokens = serde_json::json!({ - "access_token": access_token, - "refresh_token": refresh_token, - }); - if let Some(aid) = account_id { - tokens["account_id"] = serde_json::Value::String(aid.to_string()); - } - if let Some(it) = id_token { - tokens["id_token"] = serde_json::Value::String(it.to_string()); - } - let json = serde_json::json!({ +fn auth_document(tokens: &Tokens) -> serde_json::Value { + serde_json::json!({ "tokens": tokens, "last_refresh": chrono::Utc::now().to_rfc3339(), - }); - let content = match serde_json::to_string_pretty(&json) { - Ok(c) => c, - Err(e) => { - eprintln!("warning: failed to serialize Codex credentials: {e}"); - return; + }) +} + +fn save_auth_tokens(path: &Path, tokens: &Tokens) -> Result<()> { + let content = serde_json::to_string_pretty(&auth_document(tokens))?; + super::helpers::atomic_write_secret(path, content.as_bytes()) + .with_context(|| format!("Failed to write Codex auth to {}", path.display())) +} + +fn persist_tokens(source: &CredentialSource, tokens: &Tokens) { + match source { + CredentialSource::File(path) => { + if let Err(e) = save_auth_tokens(path, tokens) { + eprintln!("warning: failed to save Codex credentials: {e}"); + } } - }; - if let Err(e) = super::helpers::atomic_write_secret(path, content.as_bytes()) { - eprintln!("warning: failed to save Codex credentials: {e}"); + CredentialSource::Store(account_id) => { + if let Err(e) = update_account_tokens(account_id, tokens.clone()) { + eprintln!("warning: failed to save Codex account credentials: {e}"); + } + } + CredentialSource::Keychain => {} } } -pub fn has_credentials() -> bool { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - if let Ok(codex_home) = std::env::var("CODEX_HOME") { - if std::path::PathBuf::from(codex_home) - .join("auth.json") - .exists() - { - return true; +fn hash_token(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + digest + .iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect::() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CodexAccountIdentity { + stable_id: String, + account_id: Option, + id_token: Option, + access_token: Option, +} + +impl CodexAccountIdentity { + fn from_tokens(tokens: &Tokens) -> Self { + let account_id = normalized_token_value(tokens.account_id.as_deref()); + let id_token = normalized_token_value(tokens.id_token.as_deref()); + let access_token = normalized_token_value(tokens.access_token.as_deref()); + let stable_id = account_id + .clone() + .or_else(|| { + id_token + .as_deref() + .map(|token| format!("id-{}", hash_token(token))) + }) + .or_else(|| { + access_token + .as_deref() + .map(|token| format!("token-{}", hash_token(token))) + }) + .unwrap_or_else(|| "account".to_string()); + + Self { + stable_id, + account_id, + id_token, + access_token, } } - if home - .join(".config") - .join("codex") - .join("auth.json") - .exists() - { - return true; + + fn stable_id(&self) -> &str { + &self.stable_id } - if home.join(".codex").join("auth.json").exists() { - return true; + + fn matches(&self, other: &Self) -> bool { + if let (Some(a_id), Some(b_id)) = (self.account_id.as_deref(), other.account_id.as_deref()) + { + return a_id == b_id; + } + + if let (Some(a_id), Some(b_id)) = (self.id_token.as_deref(), other.id_token.as_deref()) { + return a_id == b_id; + } + + match (self.access_token.as_deref(), other.access_token.as_deref()) { + (Some(a_token), Some(b_token)) => a_token == b_token, + _ => false, + } } - super::helpers::read_keychain("Codex Auth").is_ok() } -async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { - let resp = client - .post("https://auth.openai.com/oauth/token") - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", CLIENT_ID), - ("refresh_token", rt), - ]) - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!("Codex token refresh failed (HTTP {})", resp.status()); +fn derive_account_id(tokens: &Tokens) -> String { + CodexAccountIdentity::from_tokens(tokens) + .stable_id() + .to_string() +} + +fn normalized_token_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn same_token_identity(a: &Tokens, b: &Tokens) -> bool { + CodexAccountIdentity::from_tokens(a).matches(&CodexAccountIdentity::from_tokens(b)) +} + +fn next_available_account_id(store: &CodexCredentialsStore, base_id: &str) -> String { + if !store.accounts.contains_key(base_id) { + return base_id.to_string(); } - Ok(resp.json().await?) + + for suffix in 2usize.. { + let candidate = format!("{base_id}-{suffix}"); + if !store.accounts.contains_key(&candidate) { + return candidate; + } + } + + unreachable!("unbounded suffix search must eventually find an unused Codex account id") } -async fn fetch_usage( - client: &reqwest::Client, - token: &str, - account_id: Option<&str>, -) -> Result { - let mut req = client - .get("https://chatgpt.com/backend-api/wham/usage") - .header("Authorization", format!("Bearer {token}")) - .header("Accept", "application/json") - .header( - "User-Agent", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", - ); - if let Some(id) = account_id { - req = req.header("ChatGPT-Account-Id", id); +fn validate_label_available( + store: &CodexCredentialsStore, + account_id: &str, + label: Option<&str>, +) -> Result<()> { + let Some(label) = label.map(str::trim).filter(|label| !label.is_empty()) else { + return Ok(()); + }; + let needle = label.to_lowercase(); + + for (id, account) in &store.accounts { + if id == account_id { + continue; + } + if account + .label + .as_deref() + .map(str::trim) + .map(str::to_lowercase) + .as_deref() + == Some(needle.as_str()) + { + anyhow::bail!("Codex account label already exists: {label}"); + } } - let resp = req.send().await?; - let status = resp.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - anyhow::bail!("NEEDS_AUTH"); + + Ok(()) +} + +pub fn load_credentials_store() -> Option { + CodexAccountStore::default().read() +} + +#[cfg(test)] +fn load_credentials_store_from_home(home_dir: &Path) -> Option { + CodexAccountStore::in_home(home_dir).read() +} + +#[cfg(test)] +fn load_credentials_store_from_path(path: &Path) -> Option { + CodexAccountStore::at_path(path).read() +} + +/// Loads the store while distinguishing "no usable store" (`Ok(None)`) from a +/// store written by a newer tokscale (`Err`). Write paths must propagate the +/// error instead of silently clobbering a future-version store; read paths can +/// treat both as "nothing usable". +fn load_credentials_store_from_path_unlocked( + path: &Path, + repair_policy: StoreRepairPolicy, +) -> Result> { + let Ok(content) = std::fs::read_to_string(path) else { + return Ok(None); + }; + bail_on_unknown_store_version(path, &content)?; + let Ok(mut store) = serde_json::from_str::(&content) else { + return Ok(None); + }; + + if store.accounts.is_empty() { + return Ok(None); } - if !status.is_success() { - anyhow::bail!("Codex usage request failed (HTTP {status})"); + + if !store.active_account_id.trim().is_empty() + && !store.accounts.contains_key(&store.active_account_id) + { + if let Some(first_id) = first_account_id(&store) { + store.active_account_id = first_id; + if repair_policy == StoreRepairPolicy::Persist { + let _ = CodexAccountStore::at_path(path).save_unlocked(&store); + } + } } - let body = resp.text().await?; - if body.trim().starts_with('<') { - anyhow::bail!("NEEDS_AUTH"); + + Ok(Some(store)) +} + +/// A future-version store may not even deserialize into the current struct, so +/// the version is checked on the raw JSON before the typed parse. +fn bail_on_unknown_store_version(path: &Path, content: &str) -> Result<()> { + let Ok(value) = serde_json::from_str::(content) else { + return Ok(()); + }; + let Some(version) = value.get("version").and_then(serde_json::Value::as_i64) else { + return Ok(()); + }; + if version != 1 { + return Err(CodexUsageError::UnsupportedStoreVersion { + version, + path: path.to_path_buf(), + } + .into()); } - Ok(serde_json::from_str(&body)?) + Ok(()) } -pub fn fetch() -> Result { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - rt.block_on(async { - let (auth, source) = read_credentials()?; - let tokens = auth - .tokens - .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; - let access_token = tokens - .access_token - .clone() - .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; - let account_id = tokens.account_id.as_deref(); - - let client = reqwest::Client::new(); - let resp = match fetch_usage(&client, &access_token, account_id).await { - Ok(r) => r, - Err(e) if e.to_string().contains("NEEDS_AUTH") => { - let rt_str = tokens - .refresh_token - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; - let refreshed = refresh_token(&client, rt_str).await?; - let new = refreshed - .access_token - .clone() - .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; - if let CredentialSource::File(ref path) = source { - let new_rt = refreshed - .refresh_token - .as_deref() - .unwrap_or_else(|| tokens.refresh_token.as_deref().unwrap_or("")); - save_credentials( - path, - &new, - new_rt, - tokens.account_id.as_deref(), - tokens.id_token.as_deref(), - ); - } - fetch_usage(&client, &new, account_id).await? - } - Err(e) => return Err(e), - }; +#[cfg(test)] +fn save_credentials_store_in_home(home_dir: &Path, store: &CodexCredentialsStore) -> Result<()> { + CodexAccountStore::in_home(home_dir).save(store) +} - let plan = resp.plan_type.as_deref().map(capitalize); - let mut metrics = Vec::new(); - if let Some(ref rl) = resp.rate_limit { - if let Some(ref w) = rl.primary_window { - let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; - metrics.push(UsageMetric { - label: "Session".into(), - used_percent: pct, - remaining_percent: 100.0 - pct, - remaining_label: None, - resets_at: w - .reset_at - .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) - .map(|dt| dt.to_rfc3339()), - }); - } - if let Some(ref w) = rl.secondary_window { - let pct = w.used_percent.unwrap_or(0).clamp(0, 100) as f64; - metrics.push(UsageMetric { - label: "Weekly".into(), - used_percent: pct, - remaining_percent: 100.0 - pct, - remaining_label: None, - resets_at: w - .reset_at - .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) - .map(|dt| dt.to_rfc3339()), - }); - } +#[cfg(test)] +fn save_credentials_store_at_path(path: &Path, store: &CodexCredentialsStore) -> Result<()> { + CodexAccountStore::at_path(path).save(store) +} + +fn resolve_account_id(store: &CodexCredentialsStore, name_or_id: &str) -> Option { + let needle = name_or_id.trim(); + if needle.is_empty() { + return None; + } + + if store.accounts.contains_key(needle) { + return Some(needle.to_string()); + } + + let needle_lower = needle.to_lowercase(); + for (id, account) in &store.accounts { + if account + .label + .as_deref() + .map(str::trim) + .map(str::to_lowercase) + .as_deref() + == Some(needle_lower.as_str()) + { + return Some(id.clone()); } + } + + None +} - Ok(UsageOutput { - provider: "Codex".into(), - plan, - email: resp.email, - metrics, +fn account_info( + store: &CodexCredentialsStore, + account_id: &str, + account: &CodexAccount, +) -> CodexAccountInfo { + CodexAccountInfo { + id: account_id.to_string(), + label: account.label.clone(), + account_id: account.tokens.account_id.clone(), + created_at: account.created_at.clone(), + is_active: account_id == store.active_account_id, + } +} + +/// Case-insensitive sort key shared by every place that orders accounts: +/// the label when present, falling back to the account id. +fn account_sort_key(label: Option<&str>, id: &str) -> String { + label.unwrap_or(id).to_lowercase() +} + +fn first_account_id(store: &CodexCredentialsStore) -> Option { + store + .accounts + .iter() + .min_by_key(|(id, account)| { + ( + account_sort_key(account.label.as_deref(), id), + (*id).clone(), + ) }) - }) + .map(|(id, _)| id.clone()) +} + +fn remove_account_from_store( + store: &mut CodexCredentialsStore, + name_or_id: &str, +) -> Result { + let resolved = resolve_account_id(store, name_or_id) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {name_or_id}"))?; + let removed_was_active = store.active_account_id == resolved; + let account = store + .accounts + .remove(&resolved) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {resolved}"))?; + let removed = CodexAccountInfo { + id: resolved, + label: account.label, + account_id: account.tokens.account_id.clone(), + created_at: account.created_at, + is_active: removed_was_active, + }; + + if removed_was_active { + store.active_account_id.clear(); + } + + Ok(removed) +} + +pub fn list_accounts() -> Vec { + let store = match load_credentials_store() { + Some(store) => store, + None => return Vec::new(), + }; + + let mut accounts: Vec<_> = store + .accounts + .iter() + .map(|(id, account)| account_info(&store, id, account)) + .collect(); + + accounts.sort_by_key(|account| { + ( + !account.is_active, + account_sort_key(account.label.as_deref(), &account.id), + ) + }); + + accounts +} + +fn save_account_from_auth(auth: Auth, label: Option<&str>) -> Result { + save_account_from_auth_at_path(&codex_store_path(), auth, label, true) +} + +fn save_account_from_auth_at_path( + store_path: &Path, + auth: Auth, + label: Option<&str>, + make_active: bool, +) -> Result { + let tokens = auth + .tokens + .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; + if tokens + .access_token + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + anyhow::bail!("No Codex access token."); + } + + let base_account_id = derive_account_id(&tokens); + CodexAccountStore::at_path(store_path).with_lock(|store_file| { + let mut store = + store_file + .read_for_update_unlocked()? + .unwrap_or_else(|| CodexCredentialsStore { + version: 1, + active_account_id: if make_active { + base_account_id.clone() + } else { + String::new() + }, + accounts: HashMap::new(), + }); + + // Scan every stored account (not just the base-id key) so an account + // stored under a collision-suffixed id (e.g. `acct_x-2`) is updated in + // place instead of re-importing as `acct_x-3`, `acct_x-4`, ... + let existing_identity_id = store + .accounts + .iter() + .find(|(_, existing)| same_token_identity(&existing.tokens, &tokens)) + .map(|(id, _)| id.clone()); + + if let Some(existing_id) = existing_identity_id { + validate_label_available(&store, &existing_id, label)?; + let label = label.map(str::trim).filter(|s| !s.is_empty()); + let active_changed = make_active && store.active_account_id != existing_id; + let mut account_changed = false; + if let Some(existing) = store.accounts.get_mut(&existing_id) { + if existing.tokens != tokens { + existing.tokens = tokens; + account_changed = true; + } + if let Some(label) = label { + if existing.label.as_deref() != Some(label) { + existing.label = Some(label.to_string()); + account_changed = true; + } + } + } + if make_active { + store.active_account_id = existing_id.clone(); + } + if account_changed || active_changed { + store_file.save_unlocked(&store)?; + } + + let account = store + .accounts + .get(&existing_id) + .ok_or_else(|| anyhow::anyhow!("Failed to save Codex account"))?; + return Ok(account_info(&store, &existing_id, account)); + } + + let account_id = if store.accounts.contains_key(&base_account_id) { + next_available_account_id(&store, &base_account_id) + } else { + base_account_id + }; + + validate_label_available(&store, &account_id, label)?; + + let account = CodexAccount { + tokens, + created_at: chrono::Utc::now().to_rfc3339(), + label: label + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + }; + + store.accounts.insert(account_id.clone(), account); + if make_active { + store.active_account_id = account_id.clone(); + } + store_file.save_unlocked(&store)?; + + let account = store + .accounts + .get(&account_id) + .ok_or_else(|| anyhow::anyhow!("Failed to save Codex account"))?; + Ok(account_info(&store, &account_id, account)) + }) +} + +pub struct CodexLoginImport { + pub info: CodexAccountInfo, + /// Non-fatal problem while snapshotting the current codex CLI login into + /// the store; surfaced in the TUI login panel. + pub warning: Option, +} + +/// Imports a freshly logged-in `auth.json` (from the TUI's temporary +/// `CODEX_HOME`) into the store without activating it. +/// +/// Before importing, the codex CLI's current login is snapshotted into the +/// store as the active account so it stays tracked alongside the new one. +/// Snapshot failure is deliberately non-fatal — the new login is the primary +/// operation — but it is reported as a warning instead of being swallowed, +/// because without the snapshot the imported account may become the store's +/// active account while the codex CLI stays logged into another. +pub fn import_login_auth_file(path: &Path) -> Result { + let store_path = codex_store_path(); + + let warning = match read_current_credentials() { + Ok((current_auth, _)) => { + save_account_from_auth_at_path(&store_path, current_auth, None, true) + .err() + .map(|e| format!("warning: failed to save current Codex login: {e}")) + } + // No current codex CLI login — nothing to snapshot. + Err(_) => None, + }; + + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read Codex auth from {}", path.display()))?; + let auth = serde_json::from_str::(&content) + .with_context(|| format!("Failed to parse Codex auth from {}", path.display()))?; + let info = save_account_from_auth_at_path(&store_path, auth, None, false)?; + + Ok(CodexLoginImport { info, warning }) +} + +pub fn save_current_auth_account() -> Result { + let (auth, _) = read_current_credentials()?; + save_account_from_auth(auth, None) +} + +fn update_account_tokens(account_id: &str, tokens: Tokens) -> Result<()> { + CodexAccountStore::default().update_existing("No saved Codex accounts", |store| { + let account = store + .accounts + .get_mut(account_id) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {account_id}"))?; + account.tokens = tokens; + Ok(()) + }) +} + +fn load_account(name_or_id: Option<&str>) -> Result<(String, CodexAccount, CodexAccountInfo)> { + let store = + load_credentials_store().ok_or_else(|| anyhow::anyhow!("No saved Codex accounts"))?; + let resolved = match name_or_id { + Some(name) => resolve_account_id(&store, name) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {name}"))?, + None if store.active_account_id.trim().is_empty() => { + anyhow::bail!("No active Codex account; pass an account name or switch to one first") + } + None => store.active_account_id.clone(), + }; + let account = store + .accounts + .get(&resolved) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {resolved}"))?; + let info = account_info(&store, &resolved, &account); + Ok((resolved, account, info)) +} + +fn auth_from_account(account: &CodexAccount) -> Auth { + Auth { + tokens: Some(account.tokens.clone()), + } +} + +pub fn has_credentials() -> bool { + if load_credentials_store() + .map(|store| !store.accounts.is_empty()) + .unwrap_or(false) + { + return true; + } + + read_current_credentials().is_ok() +} + +async fn refresh_token(client: &reqwest::Client, rt: &str) -> Result { + let resp = client + .post("https://auth.openai.com/oauth/token") + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", CLIENT_ID), + ("refresh_token", rt), + ]) + .send() + .await?; + if !resp.status().is_success() { + anyhow::bail!("Codex token refresh failed (HTTP {})", resp.status()); + } + Ok(resp.json().await?) +} + +fn parse_chatgpt_json_body(body: &str) -> Result +where + T: DeserializeOwned, +{ + if body.trim_start().starts_with('<') { + return Err(CodexUsageError::NeedsAuth.into()); + } + Ok(serde_json::from_str(body)?) +} + +async fn parse_chatgpt_json_response(resp: reqwest::Response, request_label: &str) -> Result +where + T: DeserializeOwned, +{ + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(CodexUsageError::NeedsAuth.into()); + } + if !status.is_success() { + anyhow::bail!("{request_label} failed (HTTP {status})"); + } + let body = resp.text().await?; + parse_chatgpt_json_body(&body) +} + +async fn fetch_usage( + client: &reqwest::Client, + token: &str, + account_id: Option<&str>, +) -> Result { + let mut req = client + .get("https://chatgpt.com/backend-api/wham/usage") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header( + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + ); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req.send().await?; + parse_chatgpt_json_response(resp, "Codex usage request").await +} + +async fn fetch_reset_credits( + client: &reqwest::Client, + token: &str, + account_id: Option<&str>, +) -> Result { + let mut req = client + .get("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header( + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + ); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req.send().await?; + parse_chatgpt_json_response(resp, "Codex reset credits request").await +} + +async fn consume_reset_credit( + client: &reqwest::Client, + token: &str, + account_id: Option<&str>, + redeem_request_id: &str, +) -> Result { + let mut req = client + .post("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume") + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header( + "User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + ) + .json(&serde_json::json!({ + "redeem_request_id": redeem_request_id, + })); + if let Some(id) = account_id { + req = req.header("ChatGPT-Account-Id", id); + } + let resp = req.send().await?; + parse_chatgpt_json_response(resp, "Codex reset request").await +} + +fn metric_from_window(label: &str, window: &Window) -> UsageMetric { + let pct = window.used_percent.unwrap_or(0.0).clamp(0.0, 100.0); + UsageMetric { + label: label.into(), + used_percent: pct, + remaining_percent: 100.0 - pct, + remaining_label: None, + resets_at: window + .reset_at + .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) + .map(|dt| dt.to_rfc3339()), + } +} + +fn rate_limit_window_label(window: &Window) -> Option { + let seconds = window.limit_window_seconds.filter(|seconds| *seconds > 0)?; + if seconds == 7 * 24 * 60 * 60 { + return Some("Weekly".to_string()); + } + if seconds % (24 * 60 * 60) == 0 { + return Some(format!("{}d", seconds / (24 * 60 * 60))); + } + if seconds % (60 * 60) == 0 { + return Some(format!("{}h", seconds / (60 * 60))); + } + if seconds % 60 == 0 { + return Some(format!("{}m", seconds / 60)); + } + Some(format!("{seconds}s")) +} + +fn metric_label( + prefix: Option<&str>, + window: &Window, + fallback: &str, + prefixed_fallback: &str, +) -> String { + let dynamic_label = rate_limit_window_label(window); + match prefix { + Some(prefix) => format!( + "{prefix} {}", + dynamic_label + .map(|label| label.to_ascii_lowercase()) + .unwrap_or_else(|| prefixed_fallback.to_string()) + ), + None => dynamic_label.unwrap_or_else(|| fallback.to_string()), + } +} + +fn push_rate_limit_metrics( + metrics: &mut Vec, + prefix: Option<&str>, + rate_limit: &RateLimit, +) { + let label_prefix = prefix.map(str::trim).filter(|label| !label.is_empty()); + if let Some(ref w) = rate_limit.primary_window { + let label = metric_label(label_prefix, w, "5h", "5h"); + metrics.push(metric_from_window(&label, w)); + } + if let Some(ref w) = rate_limit.secondary_window { + let label = metric_label(label_prefix, w, "Weekly", "week"); + metrics.push(metric_from_window(&label, w)); + } +} + +fn reset_credits_from_summary(summary: Option<&ResetCreditsSummary>) -> Option { + summary.and_then(|summary| { + summary + .available_count + .map(|available_count| UsageResetCredits { + available_count, + credits: Vec::new(), + }) + }) +} + +fn reset_credits_from_response(response: ResetCreditsResponse) -> Option { + response + .available_count + .map(|available_count| UsageResetCredits { + available_count, + credits: response + .credits + .into_iter() + .map(|credit| UsageResetCredit { + id: credit.id, + status: credit.status, + reset_type: credit.reset_type, + expires_at: credit.expires_at, + title: credit.title, + description: credit.description, + }) + .collect(), + }) +} + +/// Decide whether to issue the extra detail GET for reset credits. +/// +/// We fetch the detail endpoint whenever the cheap inline summary leaves the +/// credit state unknown (absent) or already reports at least one available +/// credit to enrich. The detail call is the only source of truth for accounts +/// whose `/wham/usage` payload omits `rate_limit_reset_credits` entirely, so +/// skipping it on an absent summary would hide reset credits that production +/// can otherwise surface. We only skip when the summary is present and +/// explicitly reports zero credits: there is nothing to enrich, and firing the +/// request on every periodic TUI refresh would needlessly raise backend request +/// volume and rate-limit risk. +fn should_fetch_reset_details(summary: Option<&UsageResetCredits>) -> bool { + summary.is_none_or(|credits| credits.available_count > 0) +} + +/// Merge the cheap summary count with an optional detail response. +/// +/// The detail response is only allowed to *replace* the summary when it carries +/// a concrete count (`Some`). A detail body whose `available_count` is null maps +/// to `None`; in that case we keep the known summary count rather than silently +/// dropping it (which would make the Reset button show nothing). +fn merge_reset_credits( + summary: Option, + details: Option, +) -> Option { + details.or(summary) +} + +fn json_scalar_string(value: Option) -> Option { + match value? { + serde_json::Value::Null => None, + serde_json::Value::String(value) => Some(value), + serde_json::Value::Number(value) => Some(value.to_string()), + serde_json::Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +async fn fetch_with_auth_async( + auth: Auth, + source: CredentialSource, + provider_name: String, + account: Option, +) -> Result { + let tokens = auth + .tokens + .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; + let access_token = tokens + .access_token + .clone() + .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; + + let client = reqwest::Client::new(); + let mut effective_tokens = tokens.clone(); + let mut effective_access_token = access_token.clone(); + let resp = match fetch_usage(&client, &access_token, tokens.account_id.as_deref()).await { + Ok(r) => r, + Err(e) if is_needs_auth(&e) => { + let rt_str = tokens + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = refresh_token(&client, rt_str).await?; + let new = refreshed + .access_token + .clone() + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + + let mut updated_tokens = tokens.clone(); + updated_tokens.access_token = Some(new.clone()); + if let Some(new_rt) = refreshed.refresh_token { + updated_tokens.refresh_token = Some(new_rt); + } + persist_tokens(&source, &updated_tokens); + effective_access_token = new.clone(); + effective_tokens = updated_tokens.clone(); + + fetch_usage(&client, &new, updated_tokens.account_id.as_deref()).await? + } + Err(e) => return Err(e), + }; + + let plan = resp.plan_type.as_deref().map(capitalize); + let mut metrics = Vec::new(); + if let Some(ref rl) = resp.rate_limit { + push_rate_limit_metrics(&mut metrics, None, rl); + } + for limit in &resp.additional_rate_limits { + if let Some(rate_limit) = &limit.rate_limit { + let label = limit + .limit_name + .as_deref() + .or(limit.metered_feature.as_deref()) + .map(capitalize); + push_rate_limit_metrics(&mut metrics, label.as_deref(), rate_limit); + } + } + + let mut reset_credits = reset_credits_from_summary(resp.rate_limit_reset_credits.as_ref()); + if should_fetch_reset_details(reset_credits.as_ref()) { + if let Ok(details) = fetch_reset_credits( + &client, + &effective_access_token, + effective_tokens.account_id.as_deref(), + ) + .await + { + // Only let the detail response replace the summary when it carries a + // concrete count; a null detail count must not drop a known summary. + reset_credits = + merge_reset_credits(reset_credits, reset_credits_from_response(details)); + } + } + + let credit_status = resp.credits.map(|credits| UsageCreditStatus { + balance: json_scalar_string(credits.balance), + has_credits: credits.has_credits, + unlimited: credits.unlimited, + overage_limit_reached: credits.overage_limit_reached, + }); + let spend_control = resp.spend_control.map(|control| UsageSpendControl { + individual_limit: json_scalar_string(control.individual_limit), + reached: control.reached, + }); + + Ok(UsageOutput { + provider: provider_name, + account, + plan, + email: resp.email, + metrics, + reset_credits, + credit_status, + spend_control, + }) +} + +fn fetch_with_auth( + auth: Auth, + source: CredentialSource, + provider_name: String, + account: Option, +) -> Result { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(fetch_with_auth_async(auth, source, provider_name, account)) +} + +pub fn fetch() -> Result { + let (auth, source) = read_current_credentials()?; + fetch_with_auth(auth, source, "Codex".into(), None) +} + +fn usage_account_from_saved( + account_id: &str, + account: &CodexAccount, + active_account_id: Option<&str>, +) -> UsageAccount { + UsageAccount { + id: account_id.to_string(), + label: account.label.clone(), + is_active: active_account_id == Some(account_id), + } +} + +fn matching_account_id_for_tokens( + store: &CodexCredentialsStore, + tokens: &Tokens, +) -> Option { + let identity = CodexAccountIdentity::from_tokens(tokens); + let derived = identity.stable_id().to_string(); + if store.accounts.get(&derived).is_some_and(|account| { + identity.matches(&CodexAccountIdentity::from_tokens(&account.tokens)) + }) { + return Some(derived); + } + + store + .accounts + .iter() + .find(|(_, account)| identity.matches(&CodexAccountIdentity::from_tokens(&account.tokens))) + .map(|(account_id, _)| account_id.clone()) + .or_else(|| store.accounts.contains_key(&derived).then_some(derived)) +} + +fn current_auth_account_id_in_store(store: &CodexCredentialsStore) -> Option { + let (auth, _) = read_current_credentials().ok()?; + let tokens = auth.tokens.as_ref()?; + matching_account_id_for_tokens(store, tokens) +} + +fn active_account_id_for_usage(store: &mut CodexCredentialsStore) -> Option { + let active_account_id = current_auth_account_id_in_store(store).or_else(|| { + (!store.active_account_id.trim().is_empty() + && store.accounts.contains_key(&store.active_account_id)) + .then(|| store.active_account_id.clone()) + }); + + match active_account_id.as_ref() { + Some(active_account_id) if store.active_account_id != *active_account_id => { + store.active_account_id = active_account_id.clone(); + } + None if !store.active_account_id.trim().is_empty() => { + store.active_account_id.clear(); + } + _ => {} + } + + active_account_id +} + +fn fetch_current_auth_report(diagnostics: Vec) -> UsageFetchReport { + match fetch() { + Ok(output) => UsageFetchReport { + outputs: vec![output], + diagnostics, + }, + Err(error) => { + let mut diagnostics = diagnostics; + diagnostics.push(UsageFetchDiagnostic::new("Codex", None, error.to_string())); + UsageFetchReport { + outputs: Vec::new(), + diagnostics, + } + } + } +} + +pub fn fetch_all() -> Result> { + let report = fetch_all_report(); + if report.outputs.is_empty() { + if let Some(diagnostic) = report.diagnostics.into_iter().next() { + anyhow::bail!("{}", diagnostic.message); + } + } + Ok(report.outputs) +} + +pub fn fetch_all_report() -> UsageFetchReport { + fetch_all_report_inner(CodexFetchIntent::ReadOnly) +} + +pub fn fetch_all_report_importing_current_auth() -> UsageFetchReport { + fetch_all_report_inner(CodexFetchIntent::SaveCurrentLogin) +} + +fn load_credentials_store_for_fetch_intent( + intent: CodexFetchIntent, +) -> Option { + match intent { + CodexFetchIntent::ReadOnly => load_credentials_store(), + CodexFetchIntent::SaveCurrentLogin => CodexAccountStore::default() + .read_for_update() + .ok() + .flatten(), + } +} + +fn fetch_all_report_inner(intent: CodexFetchIntent) -> UsageFetchReport { + let mut diagnostics = Vec::new(); + let current_auth_account = if intent == CodexFetchIntent::SaveCurrentLogin { + match save_current_auth_account() { + Ok(info) => Some(info), + Err(error) => { + if !is_missing_credentials(&error) { + diagnostics.push(UsageFetchDiagnostic::with_kind( + "Codex", + None, + UsageFetchDiagnosticKind::ImportCurrentLoginFailed, + UsageFetchDiagnosticSeverity::Warning, + format!("failed to import current Codex login: {error}"), + )); + } + None + } + } + } else { + None + }; + + let Some(mut store) = load_credentials_store_for_fetch_intent(intent) else { + return fetch_current_auth_report(diagnostics); + }; + + if store.accounts.is_empty() { + return fetch_current_auth_report(diagnostics); + } + + let active_account_id = current_auth_account + .map(|account| account.id) + .or_else(|| active_account_id_for_usage(&mut store)); + let mut account_ids: Vec<_> = store.accounts.keys().cloned().collect(); + account_ids.sort_by(|a, b| { + if active_account_id.as_deref() == Some(a.as_str()) { + std::cmp::Ordering::Less + } else if active_account_id.as_deref() == Some(b.as_str()) { + std::cmp::Ordering::Greater + } else { + let la = store + .accounts + .get(a) + .and_then(|account| account.label.as_deref()) + .map(|label| account_sort_key(Some(label), a)) + .unwrap_or_else(|| account_sort_key(None, a)); + let lb = store + .accounts + .get(b) + .and_then(|account| account.label.as_deref()) + .map(|label| account_sort_key(Some(label), b)) + .unwrap_or_else(|| account_sort_key(None, b)); + la.cmp(&lb).then_with(|| a.cmp(b)) + } + }); + + let mut outputs = Vec::new(); + for account_id in account_ids { + let Some(account) = store.accounts.get(&account_id) else { + continue; + }; + let usage_account = + usage_account_from_saved(&account_id, account, active_account_id.as_deref()); + match fetch_with_auth( + auth_from_account(account), + CredentialSource::Store(account_id.clone()), + "Codex".into(), + Some(usage_account.clone()), + ) { + Ok(output) => outputs.push(output), + Err(error) => diagnostics.push(UsageFetchDiagnostic::new( + "Codex", + Some(usage_account), + error.to_string(), + )), + } + } + + UsageFetchReport { + outputs, + diagnostics, + } +} + +async fn consume_reset_credit_with_auth_async( + auth: Auth, + source: CredentialSource, +) -> Result { + let tokens = auth + .tokens + .ok_or_else(|| anyhow::anyhow!("No Codex tokens."))?; + let access_token = tokens + .access_token + .clone() + .ok_or_else(|| anyhow::anyhow!("No Codex access token."))?; + let client = reqwest::Client::new(); + let redeem_request_id = uuid::Uuid::new_v4().to_string(); + + match consume_reset_credit( + &client, + &access_token, + tokens.account_id.as_deref(), + &redeem_request_id, + ) + .await + { + Ok(result) => Ok(result), + Err(e) if is_needs_auth(&e) => { + let rt_str = tokens + .refresh_token + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No refresh token."))?; + let refreshed = refresh_token(&client, rt_str).await?; + let new = refreshed + .access_token + .clone() + .ok_or_else(|| anyhow::anyhow!("Refresh returned no token."))?; + + let mut updated_tokens = tokens.clone(); + updated_tokens.access_token = Some(new.clone()); + if let Some(new_rt) = refreshed.refresh_token { + updated_tokens.refresh_token = Some(new_rt); + } + persist_tokens(&source, &updated_tokens); + + consume_reset_credit( + &client, + &new, + updated_tokens.account_id.as_deref(), + &redeem_request_id, + ) + .await + } + Err(e) => Err(e), + } +} + +pub fn consume_rate_limit_reset_credit(name_or_id: &str) -> Result { + let store = + load_credentials_store().ok_or_else(|| anyhow::anyhow!("No saved Codex accounts"))?; + let resolved = resolve_account_id(&store, name_or_id) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {name_or_id}"))?; + let account = store + .accounts + .get(&resolved) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {resolved}"))?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(consume_reset_credit_with_auth_async( + auth_from_account(&account), + CredentialSource::Store(resolved), + )) +} + +fn fetch_saved_account(name_or_id: Option<&str>) -> Result<(CodexAccountInfo, UsageOutput)> { + let (account_id, account, info) = load_account(name_or_id)?; + let usage_account = UsageAccount { + id: info.id.clone(), + label: info.label.clone(), + is_active: info.is_active, + }; + let usage = fetch_with_auth( + auth_from_account(&account), + CredentialSource::Store(account_id), + "Codex".into(), + Some(usage_account), + )?; + Ok((info, usage)) +} + +pub fn import_current_account(label: Option<&str>) -> Result { + let (auth, _) = read_current_credentials()?; + save_account_from_auth(auth, label) +} + +pub fn switch_active_account(name_or_id: &str) -> Result { + CodexAccountStore::default().update_existing("No saved Codex accounts", |store| { + let resolved = resolve_account_id(store, name_or_id) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {name_or_id}"))?; + let account = store + .accounts + .get(&resolved) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {resolved}"))?; + + let path = auth_write_path()?; + save_auth_tokens(&path, &account.tokens)?; + + store.active_account_id = resolved.clone(); + + Ok(account_info(store, &resolved, &account)) + }) +} + +/// Removes an account from tokscale's store only. The codex CLI's own +/// `auth.json` is intentionally left untouched: rewriting it would silently +/// re-log the codex CLI into a different account (or log it out entirely). +pub fn remove_account(name_or_id: &str) -> Result { + CodexAccountStore::default().update_existing("No saved Codex accounts", |store| { + let resolved = resolve_account_id(store, name_or_id) + .ok_or_else(|| anyhow::anyhow!("Codex account not found: {name_or_id}"))?; + let active_account_id = current_auth_account_id_in_store(store).or_else(|| { + (!store.active_account_id.trim().is_empty()).then(|| store.active_account_id.clone()) + }); + if active_account_id.as_deref() == Some(resolved.as_str()) { + anyhow::bail!( + "Cannot remove the active Codex account. Switch to another Codex account or log out of Codex first." + ); + } + remove_account_from_store(store, &resolved) + }) +} + +pub fn run_codex_import(name: Option) -> Result<()> { + use colored::Colorize; + + let info = import_current_account(name.as_deref())?; + let display = info.label.as_deref().unwrap_or(&info.id); + + println!("\n {}\n", "Codex - Import".cyan()); + println!( + " {}", + format!("Imported Codex account {}", display.bold()).green() + ); + println!("{}", format!(" Account ID: {}", info.id).bright_black()); + println!(); + + Ok(()) +} + +pub fn run_codex_accounts(json: bool) -> Result<()> { + use colored::Colorize; + + let accounts = list_accounts(); + if json { + #[derive(Serialize)] + struct Output { + accounts: Vec, + } + println!("{}", serde_json::to_string_pretty(&Output { accounts })?); + return Ok(()); + } + + if accounts.is_empty() { + println!("\n {}\n", "No saved Codex accounts.".yellow()); + return Ok(()); + } + + println!("{}", "\n Codex - Accounts\n".cyan()); + for account in &accounts { + let name = if let Some(label) = &account.label { + format!("{} ({})", label, account.id) + } else { + account.id.clone() + }; + let marker = if account.is_active { "*" } else { "-" }; + let marker_colored = if account.is_active { + marker.green().to_string() + } else { + marker.bright_black().to_string() + }; + println!(" {} {}", marker_colored, name); + if let Some(account_id) = &account.account_id { + println!( + "{}", + format!(" Account ID: {}", account_id).bright_black() + ); + } + } + println!(); + + Ok(()) +} + +pub fn run_codex_switch(name: &str) -> Result<()> { + use colored::Colorize; + + let info = switch_active_account(name)?; + let display = info.label.as_deref().unwrap_or(&info.id); + + println!( + "\n {}\n", + format!("Active Codex account set to {}", display.bold()).green() + ); + + Ok(()) +} + +pub fn run_codex_remove(name: &str) -> Result<()> { + use colored::Colorize; + + let info = remove_account(name)?; + let display = info.label.as_deref().unwrap_or(&info.id); + + println!( + "\n {}", + format!("Stopped tracking Codex account {}", display.bold()).green() + ); + println!( + "{}\n", + " The codex CLI login was not changed.".bright_black() + ); + + Ok(()) +} + +pub fn run_codex_status(name: Option, json: bool) -> Result<()> { + use colored::Colorize; + + let result = if name.is_some() || load_credentials_store().is_some() { + fetch_saved_account(name.as_deref()).map(|(account, usage)| (Some(account), usage)) + } else { + fetch().map(|usage| (None, usage)) + }; + + if json { + #[derive(Serialize)] + struct Output { + #[serde(skip_serializing_if = "Option::is_none")] + account: Option, + #[serde(skip_serializing_if = "Option::is_none")] + usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + } + let output = match result { + Ok((account, usage)) => Output { + account, + usage: Some(usage), + error: None, + }, + Err(e) => Output { + account: None, + usage: None, + error: Some(e.to_string()), + }, + }; + println!("{}", serde_json::to_string_pretty(&output)?); + return Ok(()); + } + + println!("\n {}\n", "Codex - Status".cyan()); + match result { + Ok((account, usage)) => { + if let Some(account) = account { + let display = account.label.as_deref().unwrap_or(&account.id); + println!("{}", format!(" Account: {}", display).white()); + if let Some(account_id) = account.account_id { + println!("{}", format!(" Account ID: {}", account_id).bright_black()); + } + } + if let Some(email) = usage.email { + println!("{}", format!(" Email: {}", email).white()); + } + if let Some(plan) = usage.plan { + println!("{}", format!(" Plan: {}", plan).white()); + } + if usage.metrics.is_empty() { + println!("{}", " No quota metrics returned.".yellow()); + } else { + for metric in usage.metrics { + let remaining = metric + .remaining_label + .unwrap_or_else(|| format!("{:.0}% left", metric.remaining_percent)); + println!( + " {} {}", + format!("{:<10}", metric.label).bright_black(), + remaining + ); + } + } + } + Err(e) => { + println!(" {}", format!("Status failed: {e}").red()); + } + } + println!(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_store_path(tmp: &TempDir) -> PathBuf { + tmp.path().join("codex-credentials.json") + } + + fn tokens(access: &str, account_id: Option<&str>) -> Tokens { + Tokens { + access_token: Some(access.to_string()), + refresh_token: Some("refresh".to_string()), + account_id: account_id.map(str::to_string), + id_token: None, + } + } + + fn tokens_with_id_token(access: &str, account_id: Option<&str>, id_token: &str) -> Tokens { + Tokens { + access_token: Some(access.to_string()), + refresh_token: Some("refresh".to_string()), + account_id: account_id.map(str::to_string), + id_token: Some(id_token.to_string()), + } + } + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set_path(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + } + + #[test] + fn usage_response_treats_null_additional_rate_limits_as_empty() -> Result<()> { + let usage: Usage = serde_json::from_value(serde_json::json!({ + "email": "plus@example.com", + "plan_type": "plus", + "rate_limit": { + "primary_window": { + "used_percent": 1, + "reset_at": 1781929382 + }, + "secondary_window": { + "used_percent": 16, + "reset_at": 1782413780 + } + }, + "additional_rate_limits": null + }))?; + + assert_eq!(usage.email.as_deref(), Some("plus@example.com")); + assert!(usage.additional_rate_limits.is_empty()); + Ok(()) + } + + #[test] + fn rate_limit_labels_follow_window_duration() -> Result<()> { + for (primary_seconds, secondary_seconds, expected) in [ + (18_000, 604_800, ["5h", "Weekly"]), + (604_800, 18_000, ["Weekly", "5h"]), + ] { + let rate_limit: RateLimit = serde_json::from_value(serde_json::json!({ + "primary_window": { + "used_percent": 0, + "limit_window_seconds": primary_seconds + }, + "secondary_window": { + "used_percent": 0, + "limit_window_seconds": secondary_seconds + } + }))?; + + let mut metrics = Vec::new(); + push_rate_limit_metrics(&mut metrics, None, &rate_limit); + assert_eq!(metric_labels(&metrics), expected); + + let mut prefixed_metrics = Vec::new(); + push_rate_limit_metrics(&mut prefixed_metrics, Some("Spark"), &rate_limit); + assert_eq!( + metric_labels(&prefixed_metrics), + expected.map(|label| format!("Spark {}", label.to_ascii_lowercase())) + ); + } + Ok(()) + } + + #[test] + fn rate_limit_labels_keep_legacy_fallbacks_without_duration() -> Result<()> { + let rate_limit: RateLimit = serde_json::from_value(serde_json::json!({ + "primary_window": { "used_percent": 10 }, + "secondary_window": { "used_percent": 20 } + }))?; + + let mut metrics = Vec::new(); + push_rate_limit_metrics(&mut metrics, None, &rate_limit); + assert_eq!(metric_labels(&metrics), ["5h", "Weekly"]); + Ok(()) + } + + fn metric_labels(metrics: &[UsageMetric]) -> Vec<&str> { + metrics.iter().map(|metric| metric.label.as_str()).collect() + } + + #[test] + fn chatgpt_json_body_treats_html_as_auth_expiry() { + let error = parse_chatgpt_json_body::( + "please sign in", + ) + .unwrap_err(); + + assert!( + is_needs_auth(&error), + "expected Codex auth expiry, got: {error:#}" + ); + } + + #[test] + fn chatgpt_json_body_parses_reset_credit_response() -> Result<()> { + let response: ResetCreditsResponse = parse_chatgpt_json_body( + r#"{"available_count":1,"credits":[{"id":"credit_1","status":"available"}]}"#, + )?; + + assert_eq!(response.available_count, Some(1)); + assert_eq!(response.credits.len(), 1); + assert_eq!(response.credits[0].id.as_deref(), Some("credit_1")); + Ok(()) + } + + #[test] + fn merge_reset_credits_preserves_summary_when_detail_count_is_null() { + // Summary reports a known non-zero count; the detail body's + // available_count is null (-> None). The summary count must survive so + // the Reset button still shows it. + let summary = Some(UsageResetCredits { + available_count: 2, + credits: Vec::new(), + }); + let details = reset_credits_from_response( + parse_chatgpt_json_body(r#"{"available_count":null}"#).unwrap(), + ); + assert!(details.is_none()); + + let merged = merge_reset_credits(summary, details); + assert_eq!(merged.expect("summary preserved").available_count, 2); + } + + #[test] + fn merge_reset_credits_prefers_detail_when_present() { + let summary = Some(UsageResetCredits { + available_count: 2, + credits: Vec::new(), + }); + let details = reset_credits_from_response( + parse_chatgpt_json_body( + r#"{"available_count":1,"credits":[{"id":"credit_1","status":"available"}]}"#, + ) + .unwrap(), + ); + + let merged = merge_reset_credits(summary, details).expect("detail applied"); + assert_eq!(merged.available_count, 1); + assert_eq!(merged.credits.len(), 1); + assert_eq!(merged.credits[0].id.as_deref(), Some("credit_1")); + } + + #[test] + fn merge_reset_credits_returns_detail_when_summary_absent() { + let details = Some(UsageResetCredits { + available_count: 3, + credits: Vec::new(), + }); + let merged = merge_reset_credits(None, details).expect("detail used"); + assert_eq!(merged.available_count, 3); + } + + #[test] + fn should_fetch_reset_details_unless_summary_is_explicitly_zero() { + // Absent summary (unknown): fetch the detail endpoint, since it is the + // only source of credits for accounts whose usage payload omits the + // inline summary. Skipping here would hide reset credits in production. + assert!(should_fetch_reset_details(None)); + // Summary present but zero credits: nothing to enrich, skip. + assert!(!should_fetch_reset_details(Some(&UsageResetCredits { + available_count: 0, + credits: Vec::new(), + }))); + // Summary present with available credits: enrich via detail call. + assert!(should_fetch_reset_details(Some(&UsageResetCredits { + available_count: 1, + credits: Vec::new(), + }))); + } + + #[test] + fn derive_account_id_prefers_account_id() { + let tokens = tokens("access-token", Some("acct_work")); + assert_eq!(derive_account_id(&tokens), "acct_work"); + } + + #[test] + fn derive_account_id_falls_back_to_stable_token_hash() { + let id = derive_account_id(&tokens("access-token", None)); + assert!(id.starts_with("token-")); + assert_eq!(id, derive_account_id(&tokens("access-token", None))); + } + + #[test] + fn same_token_identity_prefers_account_id_over_rotating_id_token() { + let a = tokens_with_id_token("access-a", Some("acct_shared"), "id-token-a"); + let b = tokens_with_id_token("access-b", Some("acct_shared"), "id-token-b"); + + assert!(same_token_identity(&a, &b)); + } + + #[test] + fn usage_active_account_matches_current_token_identity() { + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ); + accounts.insert( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("personal".to_string()), + }, + ); + let store = CodexCredentialsStore { + version: 1, + active_account_id: "acct_a".to_string(), + accounts, + }; + + let current_tokens = tokens("rotated-access-b", Some("acct_b")); + let active_id = matching_account_id_for_tokens(&store, ¤t_tokens); + assert_eq!(active_id.as_deref(), Some("acct_b")); + + let account_a = store.accounts.get("acct_a").unwrap(); + let account_b = store.accounts.get("acct_b").unwrap(); + assert!(!usage_account_from_saved("acct_a", account_a, active_id.as_deref()).is_active); + assert!(usage_account_from_saved("acct_b", account_b, active_id.as_deref()).is_active); + } + + #[test] + fn usage_active_account_handles_collision_suffixed_account_ids() { + let mut accounts = HashMap::new(); + accounts.insert( + "acct_shared".to_string(), + CodexAccount { + tokens: tokens_with_id_token("access-a", Some("acct_other"), "id-token-a"), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("old".to_string()), + }, + ); + accounts.insert( + "acct_shared-2".to_string(), + CodexAccount { + tokens: tokens_with_id_token("access-b", Some("acct_shared"), "id-token-b"), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("current".to_string()), + }, + ); + let store = CodexCredentialsStore { + version: 1, + active_account_id: "acct_shared".to_string(), + accounts, + }; + + let current_tokens = + tokens_with_id_token("rotated-access", Some("acct_shared"), "id-token-b"); + assert_eq!( + matching_account_id_for_tokens(&store, ¤t_tokens).as_deref(), + Some("acct_shared-2") + ); + } + + #[test] + #[serial_test::serial] + fn active_account_id_for_usage_updates_only_loaded_snapshot() -> Result<()> { + let config = TempDir::new()?; + let codex_home = TempDir::new()?; + let auth = Auth { + tokens: Some(tokens("rotated-access-b", Some("acct_b"))), + }; + std::fs::write( + codex_home.path().join("auth.json"), + serde_json::to_string_pretty(&auth)?, + )?; + let store_path = test_store_path(&config); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "acct_a".to_string(), + accounts: HashMap::from([ + ( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ), + ( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("personal".to_string()), + }, + ), + ]), + }, + )?; + + let _config_guard = EnvVarGuard::set_path("TOKSCALE_CONFIG_DIR", config.path()); + let _codex_guard = EnvVarGuard::set_path("CODEX_HOME", codex_home.path()); + + let mut loaded = load_credentials_store().expect("test store should load"); + let active_id = active_account_id_for_usage(&mut loaded); + + assert_eq!(active_id.as_deref(), Some("acct_b")); + assert_eq!(loaded.active_account_id, "acct_b"); + let persisted: CodexCredentialsStore = + serde_json::from_str(&std::fs::read_to_string(&store_path)?)?; + assert_eq!(persisted.active_account_id, "acct_a"); + Ok(()) + } + + #[test] + fn load_credentials_store_repairs_missing_active_account() -> Result<()> { + let tmp = TempDir::new()?; + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("zulu".to_string()), + }, + ); + accounts.insert( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("alpha".to_string()), + }, + ); + let store = CodexCredentialsStore { + version: 1, + active_account_id: "missing".to_string(), + accounts, + }; + let store_path = test_store_path(&tmp); + save_credentials_store_at_path(&store_path, &store)?; + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.active_account_id, "acct_b"); + Ok(()) + } + + #[test] + fn load_credentials_store_read_path_does_not_persist_active_account_repair() -> Result<()> { + let tmp = TempDir::new()?; + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("zulu".to_string()), + }, + ); + accounts.insert( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("alpha".to_string()), + }, + ); + let store_path = test_store_path(&tmp); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "missing".to_string(), + accounts, + }, + )?; + let lock_path = CodexAccountStore::at_path(&store_path).lock_path(); + if lock_path.exists() { + std::fs::remove_file(&lock_path)?; + } + let before = std::fs::read_to_string(&store_path)?; + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + let after = std::fs::read_to_string(&store_path)?; + + assert_eq!(loaded.active_account_id, "acct_b"); + assert_eq!(after, before); + assert!(!lock_path.exists()); + Ok(()) + } + + #[test] + #[serial_test::serial] + fn usage_surface_store_loader_persists_active_account_repair() -> Result<()> { + let config = TempDir::new()?; + let store_path = test_store_path(&config); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "missing".to_string(), + accounts: HashMap::from([ + ( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("zulu".to_string()), + }, + ), + ( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("alpha".to_string()), + }, + ), + ]), + }, + )?; + + let _config_guard = EnvVarGuard::set_path("TOKSCALE_CONFIG_DIR", config.path()); + + let loaded = load_credentials_store_for_fetch_intent(CodexFetchIntent::SaveCurrentLogin); + + assert_eq!( + loaded + .as_ref() + .map(|store| store.active_account_id.as_str()), + Some("acct_b") + ); + let persisted: CodexCredentialsStore = + serde_json::from_str(&std::fs::read_to_string(&store_path)?)?; + assert_eq!(persisted.active_account_id, "acct_b"); + Ok(()) + } + + #[test] + fn load_credentials_store_preserves_empty_active_account() -> Result<()> { + let tmp = TempDir::new()?; + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ); + let store = CodexCredentialsStore { + version: 1, + active_account_id: String::new(), + accounts, + }; + let store_path = test_store_path(&tmp); + save_credentials_store_at_path(&store_path, &store)?; + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert!(loaded.active_account_id.is_empty()); + let account = loaded.accounts.get("acct_a").unwrap(); + assert!(!account_info(&loaded, "acct_a", account).is_active); + Ok(()) + } + + #[test] + fn resolve_account_id_matches_label_case_insensitively() { + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("Work".to_string()), + }, + ); + let store = CodexCredentialsStore { + version: 1, + active_account_id: "acct_a".to_string(), + accounts, + }; + + assert_eq!( + resolve_account_id(&store, "work").as_deref(), + Some("acct_a") + ); + } + + #[test] + fn save_account_from_auth_at_path_imports_tokens_without_touching_real_home() -> Result<()> { + let tmp = TempDir::new()?; + let store_path = test_store_path(&tmp); + let info = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-a", Some("acct_a"))), + }, + Some("work"), + true, + )?; + + assert_eq!(info.id, "acct_a"); + assert_eq!(info.label.as_deref(), Some("work")); + assert!(info.is_active); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.active_account_id, "acct_a"); + assert!(loaded.accounts.contains_key("acct_a")); + Ok(()) + } + + #[test] + #[serial_test::serial] + fn save_current_auth_account_imports_codex_home_auth_when_store_missing() -> Result<()> { + let config = TempDir::new()?; + let codex_home = TempDir::new()?; + let auth = Auth { + tokens: Some(tokens("access-current", Some("acct_current"))), + }; + std::fs::write( + codex_home.path().join("auth.json"), + serde_json::to_string_pretty(&auth)?, + )?; + + let _config_guard = EnvVarGuard::set_path("TOKSCALE_CONFIG_DIR", config.path()); + let _codex_guard = EnvVarGuard::set_path("CODEX_HOME", codex_home.path()); + + let info = save_current_auth_account()?; + assert_eq!(info.id, "acct_current"); + assert!(info.is_active); + + let store_path = test_store_path(&config); + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.active_account_id, "acct_current"); + let account = loaded.accounts.get("acct_current").unwrap(); + assert_eq!( + account.tokens.access_token.as_deref(), + Some("access-current") + ); + Ok(()) + } + + #[test] + #[serial_test::serial] + fn save_current_auth_account_imports_codex_home_auth_when_store_has_other_accounts( + ) -> Result<()> { + let config = TempDir::new()?; + let codex_home = TempDir::new()?; + let auth = Auth { + tokens: Some(tokens("access-current", Some("acct_current"))), + }; + std::fs::write( + codex_home.path().join("auth.json"), + serde_json::to_string_pretty(&auth)?, + )?; + let store_path = test_store_path(&config); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: String::new(), + accounts: HashMap::from([ + ( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ), + ( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("personal".to_string()), + }, + ), + ]), + }, + )?; + + let _config_guard = EnvVarGuard::set_path("TOKSCALE_CONFIG_DIR", config.path()); + let _codex_guard = EnvVarGuard::set_path("CODEX_HOME", codex_home.path()); + + let info = save_current_auth_account()?; + assert_eq!(info.id, "acct_current"); + assert!(info.is_active); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.active_account_id, "acct_current"); + assert_eq!(loaded.accounts.len(), 3); + assert!(loaded.accounts.contains_key("acct_a")); + assert!(loaded.accounts.contains_key("acct_b")); + assert!(loaded.accounts.contains_key("acct_current")); + Ok(()) + } + + #[test] + fn save_account_from_auth_at_path_preserves_label_when_updating_same_account() -> Result<()> { + let tmp = TempDir::new()?; + let store_path = test_store_path(&tmp); + save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-a", Some("acct_a"))), + }, + Some("work"), + true, + )?; + + let info = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-b", Some("acct_a"))), + }, + None, + true, + )?; + + assert_eq!(info.id, "acct_a"); + assert_eq!(info.label.as_deref(), Some("work")); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.accounts.len(), 1); + let account = loaded.accounts.get("acct_a").unwrap(); + assert_eq!(account.label.as_deref(), Some("work")); + assert_eq!(account.tokens.access_token.as_deref(), Some("access-b")); + Ok(()) + } + + #[test] + fn save_account_from_auth_at_path_keeps_existing_account_on_identity_collision() -> Result<()> { + let tmp = TempDir::new()?; + let store_path = test_store_path(&tmp); + let mut accounts = HashMap::new(); + accounts.insert( + "acct_shared".to_string(), + CodexAccount { + tokens: tokens_with_id_token("access-a", Some("acct_other"), "id-token-a"), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "acct_shared".to_string(), + accounts, + }, + )?; + + let info = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens_with_id_token( + "access-b", + Some("acct_shared"), + "id-token-b", + )), + }, + None, + true, + )?; + + assert_eq!(info.id, "acct_shared-2"); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.accounts.len(), 2); + assert_eq!(loaded.active_account_id, "acct_shared-2"); + assert_eq!( + loaded + .accounts + .get("acct_shared") + .and_then(|account| account.label.as_deref()), + Some("work") + ); + assert!(loaded.accounts.contains_key("acct_shared-2")); + Ok(()) + } + + #[test] + fn save_account_from_auth_at_path_can_add_without_changing_active_account() -> Result<()> { + let tmp = TempDir::new()?; + let store_path = test_store_path(&tmp); + save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-a", Some("acct_a"))), + }, + Some("work"), + true, + )?; + + let info = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-b", Some("acct_b"))), + }, + Some("personal"), + false, + )?; + + assert_eq!(info.id, "acct_b"); + assert!(!info.is_active); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.active_account_id, "acct_a"); + assert!(loaded.accounts.contains_key("acct_a")); + assert!(loaded.accounts.contains_key("acct_b")); + Ok(()) + } + + #[test] + fn save_account_from_auth_at_path_keeps_empty_active_when_inactive_import_is_first_account( + ) -> Result<()> { + let tmp = TempDir::new()?; + let store_path = test_store_path(&tmp); + let info = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-a", Some("acct_a"))), + }, + Some("work"), + false, + )?; + + assert_eq!(info.id, "acct_a"); + assert!(!info.is_active); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert!(loaded.active_account_id.is_empty()); + assert!(loaded.accounts.contains_key("acct_a")); + Ok(()) + } + + #[test] + #[serial_test::serial] + fn remove_account_refuses_current_auth_account() -> Result<()> { + let config = TempDir::new()?; + let codex_home = TempDir::new()?; + let auth = Auth { + tokens: Some(tokens("access-current", Some("acct_current"))), + }; + std::fs::write( + codex_home.path().join("auth.json"), + serde_json::to_string_pretty(&auth)?, + )?; + let store_path = test_store_path(&config); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "acct_current".to_string(), + accounts: HashMap::from([ + ( + "acct_current".to_string(), + CodexAccount { + tokens: tokens("access-current", Some("acct_current")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ), + ( + "acct_other".to_string(), + CodexAccount { + tokens: tokens("access-other", Some("acct_other")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("personal".to_string()), + }, + ), + ]), + }, + )?; + + let _config_guard = EnvVarGuard::set_path("TOKSCALE_CONFIG_DIR", config.path()); + let _codex_guard = EnvVarGuard::set_path("CODEX_HOME", codex_home.path()); + + let error = + remove_account("acct_current").expect_err("current auth account must not be removable"); + assert!( + error.to_string().contains("Cannot remove the active"), + "unexpected error: {error:#}" + ); + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert!(loaded.accounts.contains_key("acct_current")); + assert!(loaded.accounts.contains_key("acct_other")); + assert_eq!(loaded.active_account_id, "acct_current"); + Ok(()) + } + + #[test] + #[serial_test::serial] + fn remove_account_refuses_store_active_account_without_current_auth() -> Result<()> { + let config = TempDir::new()?; + let codex_home = TempDir::new()?; + let store_path = test_store_path(&config); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "acct_current".to_string(), + accounts: HashMap::from([ + ( + "acct_current".to_string(), + CodexAccount { + tokens: tokens("access-current", Some("acct_current")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ), + ( + "acct_other".to_string(), + CodexAccount { + tokens: tokens("access-other", Some("acct_other")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("personal".to_string()), + }, + ), + ]), + }, + )?; + + let _config_guard = EnvVarGuard::set_path("TOKSCALE_CONFIG_DIR", config.path()); + let _codex_guard = EnvVarGuard::set_path("CODEX_HOME", codex_home.path()); + + let error = + remove_account("acct_current").expect_err("store active account must not be removable"); + assert!( + error.to_string().contains("Cannot remove the active"), + "unexpected error: {error:#}" + ); + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert!(loaded.accounts.contains_key("acct_current")); + assert!(loaded.accounts.contains_key("acct_other")); + assert_eq!(loaded.active_account_id, "acct_current"); + Ok(()) + } + + #[test] + fn remove_account_from_store_keeps_active_when_removing_inactive() -> Result<()> { + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("Work".to_string()), + }, + ); + accounts.insert( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("Personal".to_string()), + }, + ); + let mut store = CodexCredentialsStore { + version: 1, + active_account_id: "acct_a".to_string(), + accounts, + }; + + let removed = remove_account_from_store(&mut store, "personal")?; + + assert_eq!(removed.id, "acct_b"); + assert!(!removed.is_active); + assert_eq!(store.active_account_id, "acct_a"); + assert!(!store.accounts.contains_key("acct_b")); + Ok(()) + } + + #[test] + fn remove_account_from_store_clears_active_when_removing_active() -> Result<()> { + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("Work".to_string()), + }, + ); + accounts.insert( + "acct_b".to_string(), + CodexAccount { + tokens: tokens("access-b", Some("acct_b")), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("Personal".to_string()), + }, + ); + let mut store = CodexCredentialsStore { + version: 1, + active_account_id: "acct_a".to_string(), + accounts, + }; + + let removed = remove_account_from_store(&mut store, "work")?; + + assert_eq!(removed.id, "acct_a"); + assert!(removed.is_active); + assert!(store.active_account_id.is_empty()); + let account_b = store.accounts.get("acct_b").unwrap(); + assert!(!usage_account_from_saved("acct_b", account_b, None).is_active); + Ok(()) + } + + #[test] + fn remove_account_from_store_clears_active_when_last_account_removed() -> Result<()> { + let mut accounts = HashMap::new(); + accounts.insert( + "acct_a".to_string(), + CodexAccount { + tokens: tokens("access-a", Some("acct_a")), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("Work".to_string()), + }, + ); + let mut store = CodexCredentialsStore { + version: 1, + active_account_id: "acct_a".to_string(), + accounts, + }; + + let removed = remove_account_from_store(&mut store, "acct_a")?; + + assert_eq!(removed.id, "acct_a"); + assert!(removed.is_active); + assert!(store.accounts.is_empty()); + assert!(store.active_account_id.is_empty()); + Ok(()) + } + + #[test] + fn save_account_from_auth_reuses_suffixed_account_with_same_identity() -> Result<()> { + let tmp = TempDir::new()?; + let store_path = tmp.path().join("codex-credentials.json"); + let mut accounts = HashMap::new(); + accounts.insert( + "acct_shared".to_string(), + CodexAccount { + tokens: tokens_with_id_token("access-a", Some("acct_other"), "id-token-a"), + created_at: "2026-01-01T00:00:00Z".to_string(), + label: Some("work".to_string()), + }, + ); + accounts.insert( + "acct_shared-2".to_string(), + CodexAccount { + tokens: tokens_with_id_token("access-b", Some("acct_shared"), "id-token-b"), + created_at: "2026-01-02T00:00:00Z".to_string(), + label: Some("personal".to_string()), + }, + ); + save_credentials_store_at_path( + &store_path, + &CodexCredentialsStore { + version: 1, + active_account_id: "acct_shared".to_string(), + accounts, + }, + )?; + + let info = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens_with_id_token( + "access-c", + Some("acct_shared"), + "id-token-c", + )), + }, + None, + true, + )?; + + assert_eq!(info.id, "acct_shared-2"); + + let loaded = load_credentials_store_from_path(&store_path).unwrap(); + assert_eq!(loaded.accounts.len(), 2); + assert!(!loaded.accounts.contains_key("acct_shared-3")); + assert_eq!( + loaded + .accounts + .get("acct_shared-2") + .and_then(|account| account.tokens.access_token.as_deref()), + Some("access-c") + ); + Ok(()) + } + + #[test] + fn save_account_from_auth_refuses_to_overwrite_future_store_version() -> Result<()> { + let tmp = TempDir::new()?; + let store_path = tmp.path().join("codex-credentials.json"); + let future_store = + r#"{"version":2,"vaults":[{"id":"acct_a","sealed":"0xdeadbeef"}],"accounts":{}}"#; + std::fs::write(&store_path, future_store)?; + + let result = save_account_from_auth_at_path( + &store_path, + Auth { + tokens: Some(tokens("access-a", Some("acct_a"))), + }, + None, + true, + ); + + let error = result.expect_err("future-version store must not be overwritten"); + assert!( + error.to_string().contains("version 2"), + "unexpected error: {error}" + ); + assert_eq!(std::fs::read_to_string(&store_path)?, future_store); + Ok(()) + } } diff --git a/crates/tokscale-cli/src/commands/usage/copilot.rs b/crates/tokscale-cli/src/commands/usage/copilot.rs index d9b46d603..995f82464 100644 --- a/crates/tokscale-cli/src/commands/usage/copilot.rs +++ b/crates/tokscale-cli/src/commands/usage/copilot.rs @@ -278,11 +278,21 @@ pub fn fetch() -> Result { } } + if metrics.is_empty() { + anyhow::bail!( + "Copilot returned no parseable usage (quota response format may have changed)" + ); + } + Ok(UsageOutput { provider: "Copilot".into(), + account: None, plan, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) }) } diff --git a/crates/tokscale-cli/src/commands/usage/grok.rs b/crates/tokscale-cli/src/commands/usage/grok.rs new file mode 100644 index 000000000..8460ed2ec --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/grok.rs @@ -0,0 +1,897 @@ +use std::io::{BufRead, Write}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use chrono::{TimeZone, Utc}; +use serde_json::Value; + +use super::{UsageMetric, UsageOutput}; + +const SUBSCRIPTIONS_URL: &str = "https://grok.com/rest/subscriptions"; +const TASK_USAGE_URL: &str = "https://grok.com/rest/tasks/usage"; +const BILLING_GRPC_URL: &str = "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; +const GROK_USER_AGENT: &str = "Grok Build"; + +#[derive(Debug, Clone)] +struct Credentials { + token: String, + email: Option, +} + +#[derive(Debug)] +enum ProtoValue<'a> { + Varint(u64), + Fixed32(u32), + Fixed64, + Bytes(&'a [u8]), +} + +fn grok_home() -> std::path::PathBuf { + std::env::var_os("GROK_HOME") + .map(std::path::PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".grok"))) + .unwrap_or_else(|| std::path::PathBuf::from(".grok")) +} + +fn auth_path() -> std::path::PathBuf { + grok_home().join("auth.json") +} + +pub fn has_credentials() -> bool { + auth_path().exists() +} + +fn read_credentials() -> Result> { + let content = std::fs::read_to_string(auth_path())?; + let doc: Value = serde_json::from_str(&content)?; + credential_candidates_from_value(&doc) +} + +fn credential_candidates_from_value(doc: &Value) -> Result> { + let entries = doc + .as_object() + .ok_or_else(|| anyhow::anyhow!("Grok auth.json must contain an object."))?; + + let mut candidates: Vec<_> = entries + .iter() + .filter_map(|(scope, value)| { + let entry = value.as_object()?; + let token = entry + .get("key") + .and_then(Value::as_str) + .filter(|value| !value.is_empty())? + .to_string(); + let email = entry + .get("email") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let priority = if scope.contains("auth.x.ai") { 0 } else { 1 }; + Some((priority, Credentials { token, email })) + }) + .collect(); + + candidates.sort_by_key(|(priority, _)| *priority); + let credentials: Vec<_> = candidates + .into_iter() + .map(|(_, credentials)| credentials) + .collect(); + + if credentials.is_empty() { + anyhow::bail!("No Grok token found. Run 'grok login'."); + } + Ok(credentials) +} + +fn bearer_request(client: &reqwest::Client, token: &str, url: &str) -> reqwest::RequestBuilder { + client + .get(url) + .header("Authorization", format!("Bearer {token}")) + .header("X-XAI-Token-Auth", "xai-grok-cli") + .header("Accept", "application/json") + .header("User-Agent", GROK_USER_AGENT) +} + +async fn fetch_subscriptions(client: &reqwest::Client, token: &str) -> Result { + let resp = bearer_request(client, token, SUBSCRIPTIONS_URL) + .send() + .await?; + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("Grok subscriptions request failed (HTTP {status})"); + } + let text = resp.text().await?; + if text.trim_start().starts_with('<') { + anyhow::bail!("Grok subscriptions returned HTML"); + } + Ok(serde_json::from_str(&text)?) +} + +async fn fetch_task_usage(client: &reqwest::Client, token: &str) -> Result { + let resp = bearer_request(client, token, TASK_USAGE_URL).send().await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Grok task usage request failed (HTTP {status})"); + } + Ok(resp.json().await?) +} + +async fn fetch_billing_grpc(client: &reqwest::Client, token: &str) -> Result> { + let resp = client + .post(BILLING_GRPC_URL) + .header("Authorization", format!("Bearer {token}")) + .header("X-XAI-Token-Auth", "xai-grok-cli") + .header("Accept", "application/grpc-web+proto") + .header("Content-Type", "application/grpc-web+proto") + .header("User-Agent", GROK_USER_AGENT) + .body(vec![0, 0, 0, 0, 0]) + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Grok billing request failed (HTTP {status})"); + } + Ok(resp.bytes().await?.to_vec()) +} + +fn fetch_agent_billing(timeout: Duration) -> Option { + let mut child = Command::new("grok") + .args(["agent", "--no-leader", "stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let stdout = child.stdout.take()?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let reader = std::io::BufReader::new(stdout); + for line in reader.lines().map_while(std::result::Result::ok) { + let _ = tx.send(line); + } + }); + + let result = (|| { + let stdin = child.stdin.as_mut()?; + let initialize = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "1", + "clientCapabilities": { + "fs": { "readTextFile": false, "writeTextFile": false }, + "terminal": false + } + } + }); + let billing = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "x.ai/billing", + "params": {} + }); + writeln!(stdin, "{}", serde_json::to_string(&initialize).ok()?).ok()?; + writeln!(stdin, "{}", serde_json::to_string(&billing).ok()?).ok()?; + stdin.flush().ok()?; + + let response = wait_for_rpc_response(&rx, 2, timeout)?; + if response.get("error").is_some() { + return None; + } + response.get("result").cloned() + })(); + + let _ = child.kill(); + let _ = child.wait(); + + result +} + +fn wait_for_rpc_response( + rx: &mpsc::Receiver, + expected_id: i64, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.checked_duration_since(Instant::now())?; + let line = rx.recv_timeout(remaining).ok()?; + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + if value.get("id").and_then(Value::as_i64) == Some(expected_id) { + return Some(value); + } + } +} + +fn title_words(raw: &str) -> String { + raw.replace(['_', '-'], " ") + .split_whitespace() + .map(|word| { + let lower = word.to_lowercase(); + let mut chars = lower.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn normalize_subscription_tier(raw: &str) -> String { + let trimmed = raw + .trim_start_matches("SUBSCRIPTION_TIER_") + .trim_start_matches("TIER_"); + title_words(trimmed) +} + +fn parse_subscription_plan(value: &Value) -> Option { + let subscriptions = value.get("subscriptions")?.as_array()?; + let chosen = subscriptions.iter().find(|sub| { + sub.get("status") + .and_then(Value::as_str) + .map(|status| status.eq_ignore_ascii_case("active")) + .unwrap_or(false) + })?; + + let tier = chosen.get("tier").and_then(Value::as_str)?; + Some(normalize_subscription_tier(tier)) +} + +fn numeric_value(value: &Value) -> Option { + if let Some(number) = value.as_f64() { + return number.is_finite().then_some(number); + } + if let Some(text) = value.as_str() { + return text.parse::().ok().filter(|number| number.is_finite()); + } + value + .as_object() + .and_then(|object| object.get("val").or_else(|| object.get("value"))) + .and_then(numeric_value) +} + +fn number_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + numeric_value(current) +} + +fn string_at(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + current.as_str().map(ToString::to_string) +} + +fn epoch_at(value: &Value, path: &[&str]) -> Option { + number_at(value, path).and_then(|ts| epoch_to_rfc3339(ts as i64)) +} + +fn format_cents(cents: f64) -> String { + format!("${:.2}", cents / 100.0) +} + +fn cycle_label(start: Option<&str>, end: Option<&str>) -> String { + let Some(start) = start else { + return "Credits".into(); + }; + let Some(end) = end else { + return "Credits".into(); + }; + let Ok(start) = chrono::DateTime::parse_from_rfc3339(start) else { + return "Credits".into(); + }; + let Ok(end) = chrono::DateTime::parse_from_rfc3339(end) else { + return "Credits".into(); + }; + let days = (end - start).num_days(); + if (6..=8).contains(&days) { + "Weekly".into() + } else if (27..=33).contains(&days) { + "Monthly".into() + } else { + "Credits".into() + } +} + +fn parse_billing_json_metric(value: &Value) -> Option { + if let Some(metric) = parse_billing_json_object(value) { + return Some(metric); + } + match value { + Value::Array(items) => items.iter().find_map(parse_billing_json_metric), + Value::Object(object) => object.values().find_map(parse_billing_json_metric), + _ => None, + } +} + +fn parse_billing_json_object(value: &Value) -> Option { + let monthly_limit = number_at(value, &["monthlyLimit"]) + .or_else(|| number_at(value, &["config", "monthlyLimit"])); + let total_used = number_at(value, &["usage", "totalUsed"]) + .or_else(|| number_at(value, &["totalUsed"])) + .or_else(|| number_at(value, &["config", "usage", "totalUsed"])); + let percent = if let (Some(limit), Some(used)) = (monthly_limit, total_used) { + if limit > 0.0 { + Some((used / limit * 100.0).clamp(0.0, 100.0)) + } else { + None + } + } else { + number_at(value, &["usedPercent"]) + .or_else(|| number_at(value, &["usagePercent"])) + .or_else(|| number_at(value, &["creditUsagePercent"])) + }?; + let percent = percent.is_finite().then(|| percent.clamp(0.0, 100.0))?; + + let start = string_at(value, &["billingCycle", "billingPeriodStart"]) + .or_else(|| string_at(value, &["billingPeriodStart"])) + .or_else(|| epoch_at(value, &["billingPeriodStart"])); + let end = string_at(value, &["billingCycle", "billingPeriodEnd"]) + .or_else(|| string_at(value, &["billingPeriodEnd"])) + .or_else(|| epoch_at(value, &["billingPeriodEnd"])); + + let remaining_label = if let (Some(limit), Some(used)) = (monthly_limit, total_used) { + let remaining = (limit - used).max(0.0); + Some(format!( + "{}/{} left", + format_cents(remaining), + format_cents(limit) + )) + } else { + None + }; + + Some(UsageMetric { + label: cycle_label(start.as_deref(), end.as_deref()), + used_percent: percent, + remaining_percent: 100.0 - percent, + remaining_label, + resets_at: end, + }) +} + +fn push_limit_metric( + metrics: &mut Vec, + label: &str, + used: Option, + limit: Option, + reset: Option, +) { + let Some(limit) = limit.filter(|limit| *limit > 0.0) else { + return; + }; + let used = used.unwrap_or(0.0).clamp(0.0, limit); + let used_percent = (used / limit * 100.0).clamp(0.0, 100.0); + let remaining_label = format!("{:.0}/{:.0} left", limit - used, limit); + if metrics.iter().any(|metric| { + metric.label == label + && (metric.used_percent - used_percent).abs() < 0.0001 + && metric.remaining_label.as_deref() == Some(remaining_label.as_str()) + }) { + return; + } + metrics.push(UsageMetric { + label: label.into(), + used_percent, + remaining_percent: 100.0 - used_percent, + remaining_label: Some(remaining_label), + resets_at: reset, + }); +} + +fn collect_task_usage_metrics(value: &Value, metrics: &mut Vec) { + if let Value::Object(object) = value { + let reset = object + .get("resetTime") + .or_else(|| object.get("resetsAt")) + .or_else(|| object.get("resetAt")) + .and_then(Value::as_str) + .map(ToString::to_string); + push_limit_metric( + metrics, + "Tasks", + object.get("usage").and_then(numeric_value), + object.get("limit").and_then(numeric_value), + reset.clone(), + ); + push_limit_metric( + metrics, + "Frequent", + object.get("frequentUsage").and_then(numeric_value), + object.get("frequentLimit").and_then(numeric_value), + reset.clone(), + ); + push_limit_metric( + metrics, + "Occasional", + object.get("occasionalUsage").and_then(numeric_value), + object.get("occasionalLimit").and_then(numeric_value), + reset, + ); + for child in object.values() { + collect_task_usage_metrics(child, metrics); + } + } else if let Value::Array(items) = value { + for child in items { + collect_task_usage_metrics(child, metrics); + } + } +} + +fn read_varint(data: &[u8], pos: &mut usize) -> Option { + let mut result = 0_u64; + let mut shift = 0_u32; + while *pos < data.len() && shift <= 63 { + let byte = data[*pos]; + *pos += 1; + result |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some(result); + } + shift += 7; + } + None +} + +fn next_proto_field<'a>(data: &'a [u8], pos: &mut usize) -> Option<(u32, ProtoValue<'a>)> { + let key = read_varint(data, pos)?; + let field = u32::try_from(key >> 3).ok()?; + let wire = key & 0x07; + match wire { + 0 => read_varint(data, pos).map(|value| (field, ProtoValue::Varint(value))), + 1 => { + if *pos + 8 > data.len() { + return None; + } + *pos += 8; + Some((field, ProtoValue::Fixed64)) + } + 2 => { + let len = usize::try_from(read_varint(data, pos)?).ok()?; + if *pos + len > data.len() { + return None; + } + let bytes = &data[*pos..*pos + len]; + *pos += len; + Some((field, ProtoValue::Bytes(bytes))) + } + 5 => { + if *pos + 4 > data.len() { + return None; + } + let value = + u32::from_le_bytes([data[*pos], data[*pos + 1], data[*pos + 2], data[*pos + 3]]); + *pos += 4; + Some((field, ProtoValue::Fixed32(value))) + } + _ => None, + } +} + +fn timestamp_message_to_rfc3339(data: &[u8]) -> Option { + let mut pos = 0; + while pos < data.len() { + let (field, value) = next_proto_field(data, &mut pos)?; + if field == 1 { + if let ProtoValue::Varint(seconds) = value { + return epoch_to_rfc3339(i64::try_from(seconds).ok()?); + } + } + } + None +} + +fn epoch_to_rfc3339(seconds: i64) -> Option { + Utc.timestamp_opt(seconds, 0) + .single() + .map(|dt| dt.to_rfc3339()) +} + +fn parse_billing_config_proto(data: &[u8]) -> Option { + let mut pos = 0; + let mut used_percent: Option = None; + let mut start: Option = None; + let mut end: Option = None; + + while pos < data.len() { + let (field, value) = next_proto_field(data, &mut pos)?; + match (field, value) { + (1, ProtoValue::Fixed32(bits)) => { + let percent = f32::from_bits(bits) as f64; + if percent.is_finite() { + used_percent = Some(percent.clamp(0.0, 100.0)); + } + } + (4, ProtoValue::Bytes(bytes)) => { + start = timestamp_message_to_rfc3339(bytes); + } + (5, ProtoValue::Bytes(bytes)) => { + end = timestamp_message_to_rfc3339(bytes); + } + _ => {} + } + } + + let percent = used_percent?; + Some(UsageMetric { + label: cycle_label(start.as_deref(), end.as_deref()), + used_percent: percent, + remaining_percent: 100.0 - percent, + remaining_label: None, + resets_at: end, + }) +} + +fn parse_grpc_billing_metric(body: &[u8]) -> Option { + let mut pos = 0; + while pos + 5 <= body.len() { + let flag = body[pos]; + let len = u32::from_be_bytes([body[pos + 1], body[pos + 2], body[pos + 3], body[pos + 4]]) + as usize; + pos += 5; + if pos + len > body.len() { + return None; + } + let payload = &body[pos..pos + len]; + pos += len; + if flag & 0x80 != 0 { + continue; + } + + let mut payload_pos = 0; + while payload_pos < payload.len() { + let (field, value) = next_proto_field(payload, &mut payload_pos)?; + if field == 1 { + if let ProtoValue::Bytes(config) = value { + if let Some(metric) = parse_billing_config_proto(config) { + return Some(metric); + } + } + } + } + } + None +} + +fn fetch_network_usage(credentials: &Credentials) -> Result { + let mut plan: Option = None; + let mut metrics = Vec::new(); + let mut errors = Vec::new(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(12)) + .build()?; + + match fetch_billing_grpc(&client, &credentials.token).await { + Ok(body) => { + if let Some(metric) = parse_grpc_billing_metric(&body) { + metrics.push(metric); + } else { + errors.push("Grok billing response was not recognized".to_string()); + } + } + Err(error) => errors.push(format!("Grok billing request failed: {error}")), + } + + if metrics.is_empty() { + match fetch_task_usage(&client, &credentials.token).await { + Ok(task_usage) => collect_task_usage_metrics(&task_usage, &mut metrics), + Err(error) => errors.push(format!("Grok task usage request failed: {error}")), + } + } + + match fetch_subscriptions(&client, &credentials.token).await { + Ok(subscriptions) => { + plan = parse_subscription_plan(&subscriptions); + } + Err(error) => errors.push(format!("Grok subscriptions request failed: {error}")), + } + + Ok::<_, anyhow::Error>(()) + })?; + + if metrics.is_empty() && plan.is_none() { + let detail = if errors.is_empty() { + "no usage or active subscription data returned".to_string() + } else { + errors.join("; ") + }; + anyhow::bail!("Grok usage unavailable: {detail}"); + } + + Ok(UsageOutput { + provider: "Grok Build".into(), + account: None, + plan, + email: credentials.email.clone(), + metrics, + reset_credits: None, + credit_status: None, + spend_control: None, + }) +} + +fn usage_output( + plan: Option, + email: Option, + metrics: Vec, +) -> UsageOutput { + UsageOutput { + provider: "Grok Build".into(), + account: None, + plan, + email, + metrics, + reset_credits: None, + credit_status: None, + spend_control: None, + } +} + +pub fn fetch() -> Result { + let credentials = read_credentials()?; + let mut errors = Vec::new(); + let mut plan_only: Option = None; + + for (index, credential) in credentials.iter().enumerate() { + match fetch_network_usage(credential) { + Ok(output) if !output.metrics.is_empty() => return Ok(output), + Ok(output) => { + if plan_only.is_none() { + plan_only = Some(output); + } + } + Err(error) => errors.push(format!("Grok credential #{} failed: {error}", index + 1)), + } + } + + // The `grok agent --no-leader stdio` billing fallback runs without + // credential context, so its metrics cannot be attributed to a specific + // account. Skip it when auth.json holds multiple credentials to avoid + // merging metrics with a plan/email that belongs to a different account. + if credentials.len() > 1 { + errors + .push("Grok agent billing fallback skipped: multiple credentials present".to_string()); + } else if let Some(billing) = fetch_agent_billing(Duration::from_secs(4)) { + let mut metrics = Vec::new(); + if let Some(metric) = parse_billing_json_metric(&billing) { + metrics.push(metric); + } + collect_task_usage_metrics(&billing, &mut metrics); + if !metrics.is_empty() { + return Ok(usage_output( + plan_only.as_ref().and_then(|output| output.plan.clone()), + plan_only + .as_ref() + .and_then(|output| output.email.clone()) + .or_else(|| { + credentials + .first() + .and_then(|credential| credential.email.clone()) + }), + metrics, + )); + } + } else { + errors.push("Grok agent billing RPC unavailable".to_string()); + } + + if let Some(output) = plan_only { + return Ok(output); + } + + let detail = if errors.is_empty() { + "no usage or active subscription data returned".to_string() + } else { + errors.join("; ") + }; + anyhow::bail!("Grok usage unavailable: {detail}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_varint(mut value: u64, out: &mut Vec) { + while value >= 0x80 { + out.push((value as u8 & 0x7f) | 0x80); + value >>= 7; + } + out.push(value as u8); + } + + fn push_len_field(field: u64, payload: &[u8], out: &mut Vec) { + push_varint((field << 3) | 2, out); + push_varint(payload.len() as u64, out); + out.extend_from_slice(payload); + } + + fn push_fixed32_field(field: u64, value: u32, out: &mut Vec) { + push_varint((field << 3) | 5, out); + out.extend_from_slice(&value.to_le_bytes()); + } + + fn timestamp_message(seconds: u64) -> Vec { + let mut out = Vec::new(); + push_varint(1 << 3, &mut out); + push_varint(seconds, &mut out); + out + } + + #[test] + fn parses_billing_json_metric() { + let value = serde_json::json!({ + "billingCycle": { + "billingPeriodStart": "2026-06-01T00:00:00Z", + "billingPeriodEnd": "2026-07-01T00:00:00Z" + }, + "monthlyLimit": { "val": 10000 }, + "usage": { + "includedUsed": { "val": 1250 }, + "onDemandUsed": { "val": 0 }, + "totalUsed": { "val": 1250 } + } + }); + + let metric = parse_billing_json_metric(&value).expect("billing metric"); + assert_eq!(metric.label, "Monthly"); + assert_eq!(metric.used_percent, 12.5); + assert_eq!( + metric.remaining_label.as_deref(), + Some("$87.50/$100.00 left") + ); + assert_eq!(metric.resets_at.as_deref(), Some("2026-07-01T00:00:00Z")); + } + + #[test] + fn rejects_non_finite_billing_json_percentages() { + for value in [ + serde_json::json!({ "usedPercent": "NaN" }), + serde_json::json!({ "usedPercent": "inf" }), + serde_json::json!({ + "monthlyLimit": "NaN", + "usage": { "totalUsed": 10 } + }), + ] { + assert!(parse_billing_json_metric(&value).is_none()); + } + } + + #[test] + fn reads_multiple_credential_candidates_with_auth_scope_first() { + let value = serde_json::json!({ + "https://example.com": { + "key": "secondary-token", + "email": "secondary@example.com" + }, + "https://auth.x.ai": { + "key": "primary-token", + "email": "primary@example.com" + } + }); + + let credentials = credential_candidates_from_value(&value).expect("credential candidates"); + assert_eq!(credentials.len(), 2); + assert_eq!(credentials[0].token, "primary-token"); + assert_eq!(credentials[0].email.as_deref(), Some("primary@example.com")); + assert_eq!(credentials[1].token, "secondary-token"); + assert_eq!( + credentials[1].email.as_deref(), + Some("secondary@example.com") + ); + } + + #[test] + fn parses_grpc_billing_percent_frame() { + let mut config = Vec::new(); + push_fixed32_field(1, 25.0_f32.to_bits(), &mut config); + push_len_field(4, ×tamp_message(1_780_272_000), &mut config); + push_len_field(5, ×tamp_message(1_782_864_000), &mut config); + + let mut message = Vec::new(); + push_len_field(1, &config, &mut message); + + let mut frame = Vec::new(); + frame.push(0); + frame.extend_from_slice(&(message.len() as u32).to_be_bytes()); + frame.extend_from_slice(&message); + + let metric = parse_grpc_billing_metric(&frame).expect("billing metric"); + assert_eq!(metric.label, "Monthly"); + assert_eq!(metric.used_percent, 25.0); + assert_eq!( + metric.resets_at.as_deref(), + Some("2026-07-01T00:00:00+00:00") + ); + } + + #[test] + fn parses_task_usage_metrics() { + let value = serde_json::json!({ + "frequentUsage": 3, + "frequentLimit": 10, + "occasionalUsage": 1, + "occasionalLimit": 5 + }); + let mut metrics = Vec::new(); + collect_task_usage_metrics(&value, &mut metrics); + + assert_eq!(metrics.len(), 2); + assert_eq!(metrics[0].label, "Frequent"); + assert_eq!(metrics[0].used_percent, 30.0); + assert_eq!(metrics[1].label, "Occasional"); + assert_eq!(metrics[1].used_percent, 20.0); + } + + #[test] + fn normalizes_grok_subscription_plan() { + let value = serde_json::json!({ + "subscriptions": [ + { + "tier": "SUBSCRIPTION_TIER_SUPER_GROK_PRO", + "status": "active" + } + ] + }); + assert_eq!( + parse_subscription_plan(&value).as_deref(), + Some("Super Grok Pro") + ); + } + + #[test] + fn ignores_inactive_subscription_plan() { + let value = serde_json::json!({ + "subscriptions": [ + { + "tier": "SUBSCRIPTION_TIER_GROK_PRO", + "status": "inactive" + } + ] + }); + + assert_eq!(parse_subscription_plan(&value), None); + } + + #[test] + fn prefers_active_subscription_plan() { + let value = serde_json::json!({ + "subscriptions": [ + { + "tier": "SUBSCRIPTION_TIER_GROK_PRO", + "status": "inactive" + }, + { + "tier": "SUBSCRIPTION_TIER_SUPER_GROK_PRO", + "status": "active" + } + ] + }); + + assert_eq!( + parse_subscription_plan(&value).as_deref(), + Some("Super Grok Pro") + ); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/helpers.rs b/crates/tokscale-cli/src/commands/usage/helpers.rs index 48d7f2597..d667c4c24 100644 --- a/crates/tokscale-cli/src/commands/usage/helpers.rs +++ b/crates/tokscale-cli/src/commands/usage/helpers.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Datelike, Duration, Local, Utc}; pub fn capitalize(s: &str) -> String { let mut c = s.chars(); @@ -27,7 +27,18 @@ pub fn format_reset_time(resets_at: &str) -> String { Ok(d) => d.with_timezone(&Utc), Err(_) => return resets_at.into(), }; - let diff = dt - Utc::now(); + let local_dt = dt.with_timezone(&Local); + let now = Utc::now(); + let display_time = compact_reset_time(local_dt, now.with_timezone(&Local), dt - now); + format_reset_time_with_now(dt, now, &display_time) +} + +fn format_reset_time_with_now( + reset_at: DateTime, + now: DateTime, + display_time: &str, +) -> String { + let diff = reset_at - now; if diff <= Duration::zero() { return "resets now".into(); } @@ -42,10 +53,18 @@ pub fn format_reset_time(resets_at: &str) -> String { } else { format!("resets in {h}h") } + } else { + format!("resets {display_time}") + } +} + +fn compact_reset_time(reset_at: DateTime, now: DateTime, diff: Duration) -> String { + if reset_at.year() != now.year() { + reset_at.format("%Y-%m-%d %H:%M").to_string() } else if diff.num_days() < 7 { - format!("resets {} {}", dt.format("%a"), dt.format("%-I%P")) + reset_at.format("%a %b %-d %H:%M").to_string() } else { - format!("resets {}", dt.format("%b %-d")) + reset_at.format("%b %-d %H:%M").to_string() } } @@ -91,3 +110,47 @@ pub fn atomic_write_secret(path: &std::path::Path, data: &[u8]) -> std::io::Resu } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn utc(value: &str) -> DateTime { + DateTime::parse_from_rfc3339(value) + .unwrap() + .with_timezone(&Utc) + } + + #[test] + fn reset_time_keeps_short_windows_relative() { + let label = format_reset_time_with_now( + utc("2026-06-25T02:45:00Z"), + utc("2026-06-25T01:30:00Z"), + "2026-06-25 10:45 +08:00", + ); + + assert_eq!(label, "resets in 1h 15m"); + } + + #[test] + fn reset_time_shows_absolute_local_time_for_daily_or_longer_windows() { + let label = format_reset_time_with_now( + utc("2026-06-27T01:30:00Z"), + utc("2026-06-25T01:30:00Z"), + "Sat Jun 27 09:30", + ); + + assert_eq!(label, "resets Sat Jun 27 09:30"); + } + + #[test] + fn reset_time_omits_weekday_for_long_windows() { + let label = format_reset_time_with_now( + utc("2026-07-18T00:43:00Z"), + utc("2026-06-25T01:30:00Z"), + "Jul 18 08:43", + ); + + assert_eq!(label, "resets Jul 18 08:43"); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/kimi.rs b/crates/tokscale-cli/src/commands/usage/kimi.rs index 2619cdd38..0d0da06b3 100644 --- a/crates/tokscale-cli/src/commands/usage/kimi.rs +++ b/crates/tokscale-cli/src/commands/usage/kimi.rs @@ -58,25 +58,47 @@ struct Membership { level: Option, } -fn read_credentials() -> Result { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let path = home - .join(".kimi") - .join("credentials") - .join("kimi-code.json"); - if !path.exists() { - anyhow::bail!("No Kimi credentials found. Run 'kimi' to log in."); +fn credentials_paths() -> Vec { + let mut paths = Vec::new(); + + // 1) kimi-code (supports KIMI_CODE_HOME override) + let kimi_code_home = std::env::var("KIMI_CODE_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .map(|h| h.join(".kimi-code")) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + }); + paths.push(kimi_code_home.join("credentials").join("kimi-code.json")); + + // 2) kimi-cli + if let Some(home) = dirs::home_dir() { + paths.push( + home.join(".kimi") + .join("credentials") + .join("kimi-code.json"), + ); + } + + paths +} + +fn read_credentials() -> Result<(Credentials, std::path::PathBuf)> { + for path in credentials_paths() { + if path.exists() { + let content = std::fs::read_to_string(&path)?; + return Ok((serde_json::from_str(&content)?, path)); + } } - let content = std::fs::read_to_string(&path)?; - Ok(serde_json::from_str(&content)?) + anyhow::bail!("No Kimi credentials found. Run 'kimi' to log in.") } -fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let path = home - .join(".kimi") - .join("credentials") - .join("kimi-code.json"); +fn save_credentials( + path: &std::path::Path, + access_token: &str, + refresh_token: &str, + expires_in: i64, +) { let expires_at = chrono::Utc::now().timestamp() as f64 + expires_in as f64; let json = serde_json::json!({ "access_token": access_token, @@ -92,7 +114,7 @@ fn save_credentials(access_token: &str, refresh_token: &str, expires_in: i64) { return; } }; - if let Err(e) = super::helpers::atomic_write_secret(&path, content.as_bytes()) { + if let Err(e) = super::helpers::atomic_write_secret(path, content.as_bytes()) { eprintln!("warning: failed to save Kimi credentials: {e}"); } } @@ -158,15 +180,11 @@ fn parse_quota_detail(label: &str, detail: &QuotaDetail) -> Option } pub fn has_credentials() -> bool { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - home.join(".kimi") - .join("credentials") - .join("kimi-code.json") - .exists() + credentials_paths().iter().any(|p| p.exists()) } pub fn fetch() -> Result { - let creds = read_credentials()?; + let (creds, creds_path) = read_credentials()?; let mut access_token = creds .access_token .clone() @@ -190,7 +208,7 @@ pub fn fetch() -> Result { (&refreshed.refresh_token, refreshed.expires_in) { stored_refresh_token = Some(new_rt.clone()); - save_credentials(&access_token, new_rt, expires_in); + save_credentials(&creds_path, &access_token, new_rt, expires_in); } } } @@ -211,7 +229,7 @@ pub fn fetch() -> Result { if let (Some(new_rt), Some(expires_in)) = (&refreshed.refresh_token, refreshed.expires_in) { - save_credentials(&new, new_rt, expires_in); + save_credentials(&creds_path, &new, new_rt, expires_in); } fetch_usage(&client, &new).await? } @@ -238,10 +256,11 @@ pub fn fetch() -> Result { }; if let Some(metric) = parse_quota_detail(label, detail) { let key = format!( - "{}:{}:{}", + "{}:{}:{}:{}", label, metric.used_percent, - metric.remaining_label.as_deref().unwrap_or("") + metric.remaining_label.as_deref().unwrap_or(""), + metric.resets_at.as_deref().unwrap_or("") ); if seen.insert(key) { metrics.push(metric); @@ -255,10 +274,11 @@ pub fn fetch() -> Result { if let Some(ref usage) = resp.usage { if let Some(metric) = parse_quota_detail("Weekly", usage) { let key = format!( - "{}:{}:{}", + "{}:{}:{}:{}", "Weekly", metric.used_percent, - metric.remaining_label.as_deref().unwrap_or("") + metric.remaining_label.as_deref().unwrap_or(""), + metric.resets_at.as_deref().unwrap_or("") ); if seen.insert(key) { metrics.push(metric); @@ -268,9 +288,13 @@ pub fn fetch() -> Result { Ok(UsageOutput { provider: "Kimi".into(), + account: None, plan, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) }) } diff --git a/crates/tokscale-cli/src/commands/usage/minimax.rs b/crates/tokscale-cli/src/commands/usage/minimax.rs index 25f1d5452..9b00ab8f1 100644 --- a/crates/tokscale-cli/src/commands/usage/minimax.rs +++ b/crates/tokscale-cli/src/commands/usage/minimax.rs @@ -221,7 +221,11 @@ pub fn fetch() -> Result { // Reset time: prefer end_time, fallback to remains_time let resets_at = model.end_time.map(parse_end_time).or_else(|| { model.remains_time.map(|rt| { - let ms = if rt > 1_000_000_000 { rt } else { rt * 1000 }; + let ms = if rt > 1_000_000_000 { + rt + } else { + rt.saturating_mul(1000) + }; let dt = Utc::now() + chrono::Duration::milliseconds(ms); dt.to_rfc3339() }) @@ -242,9 +246,13 @@ pub fn fetch() -> Result { Ok(UsageOutput { provider: "MiniMax".into(), + account: None, plan, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) }) } diff --git a/crates/tokscale-cli/src/commands/usage/minimax_tokenplan.rs b/crates/tokscale-cli/src/commands/usage/minimax_tokenplan.rs new file mode 100644 index 000000000..95127cf56 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/minimax_tokenplan.rs @@ -0,0 +1,311 @@ +use anyhow::Result; +use chrono::{TimeZone, Utc}; +use serde::Deserialize; + +use super::{UsageAccount, UsageMetric, UsageOutput}; + +const TOKEN_PLAN_PATH: &str = "/v1/token_plan/remains"; + +// MiniMax runs separate token-plan backends for its domestic (minimaxi.com) and +// international (minimax.io) sites, each behind its own API key. +struct Site { + label: &'static str, + base_url: &'static str, + key_env: &'static str, +} + +const SITES: &[Site] = &[ + Site { + label: "CN", + base_url: "https://www.minimaxi.com", + key_env: "MINIMAX_TOKEN_PLAN_CN_KEY", + }, + Site { + label: "Global", + base_url: "https://www.minimax.io", + key_env: "MINIMAX_TOKEN_PLAN_GLOBAL_KEY", + }, +]; + +#[derive(Debug, Deserialize)] +struct ApiResponse { + base_resp: Option, + model_remains: Option>, +} + +#[derive(Debug, Deserialize)] +struct BaseResp { + status_code: Option, + status_msg: Option, +} + +#[derive(Debug, Deserialize)] +struct ModelRemains { + model_name: Option, + current_interval_remaining_percent: Option, + end_time: Option, + current_weekly_status: Option, + current_weekly_remaining_percent: Option, + weekly_end_time: Option, +} + +fn read_key(site: &Site) -> Option { + let key = std::env::var(site.key_env).ok()?; + let trimmed = key.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn has_credentials() -> bool { + SITES.iter().any(|s| read_key(s).is_some()) +} + +fn epoch_ms_to_rfc3339(ts: i64) -> Option { + let ms = if ts.abs() > 10_000_000_000 { + ts + } else { + ts * 1000 + }; + Utc.timestamp_millis_opt(ms) + .single() + .map(|dt| dt.to_rfc3339()) +} + +fn is_auth_error(resp: &ApiResponse) -> bool { + matches!( + resp.base_resp.as_ref().and_then(|b| b.status_code), + Some(1004) + ) +} + +fn is_api_error(resp: &ApiResponse) -> bool { + resp.base_resp + .as_ref() + .and_then(|b| b.status_code) + .map(|code| code != 0) + .unwrap_or(false) +} + +fn build_metrics(remains: &[ModelRemains]) -> Vec { + let mut metrics = Vec::new(); + for m in remains { + let name = m.model_name.as_deref().unwrap_or("model"); + + if let Some(pct) = m.current_interval_remaining_percent { + let remaining = pct.clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: name.to_string(), + used_percent: 100.0 - remaining, + remaining_percent: remaining, + remaining_label: None, + resets_at: m.end_time.and_then(epoch_ms_to_rfc3339), + }); + } + + // Skip when the plan has no weekly limit: an inactive status must not be + // rendered as "0% left / 100% used". + if m.current_weekly_status.is_some_and(|s| s != 0) { + if let Some(pct) = m.current_weekly_remaining_percent { + let remaining = pct.clamp(0, 100) as f64; + metrics.push(UsageMetric { + label: format!("{name}·wk"), + used_percent: 100.0 - remaining, + remaining_percent: remaining, + remaining_label: None, + resets_at: m.weekly_end_time.and_then(epoch_ms_to_rfc3339), + }); + } + } + } + metrics +} + +async fn fetch_site(client: &reqwest::Client, site: &Site, key: &str) -> Result { + let url = format!("{}{TOKEN_PLAN_PATH}", site.base_url); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {key}")) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .send() + .await?; + + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!( + "MiniMax Token Plan ({}) session expired; check your API key", + site.label + ); + } + if !status.is_success() { + anyhow::bail!( + "MiniMax Token Plan ({}) request failed (HTTP {status})", + site.label + ); + } + Ok(resp.json().await?) +} + +pub fn fetch_all() -> Result> { + let targets: Vec<&Site> = SITES.iter().filter(|s| read_key(s).is_some()).collect(); + if targets.is_empty() { + return Ok(vec![]); + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async { + let client = reqwest::Client::new(); + let mut outputs = Vec::new(); + + for site in targets { + let key = read_key(site).unwrap_or_default(); + let resp = match fetch_site(&client, site, &key).await { + Ok(r) => r, + Err(e) => { + eprintln!("MiniMax Token Plan ({}): {e}", site.label); + continue; + } + }; + + if is_auth_error(&resp) { + eprintln!( + "MiniMax Token Plan ({}): session expired; check your API key", + site.label + ); + continue; + } + if is_api_error(&resp) { + let msg = resp + .base_resp + .as_ref() + .and_then(|b| b.status_msg.clone()) + .unwrap_or_else(|| "unknown error".into()); + eprintln!("MiniMax Token Plan ({}): {msg}", site.label); + continue; + } + + let remains = resp.model_remains.as_deref().unwrap_or(&[]); + let metrics = build_metrics(remains); + // Skip sites with no renderable windows so we don't emit a bare header row. + if metrics.is_empty() { + continue; + } + outputs.push(UsageOutput { + provider: "MiniMax Token Plan".into(), + account: Some(UsageAccount { + id: site.label.to_string(), + label: Some(site.label.to_string()), + is_active: true, + }), + plan: None, + email: None, + metrics, + reset_credits: None, + credit_status: None, + spend_control: None, + }); + } + + Ok(outputs) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Real token_plan/remains payload (status_code 0 = success). Each model has + // both an interval (short) and weekly window, measured by remaining_percent. + const SAMPLE: &str = r#"{"model_remains":[{"start_time":1781834400000,"end_time":1781852400000,"remains_time":16640796,"current_interval_total_count":0,"current_interval_usage_count":0,"model_name":"general","current_weekly_total_count":0,"current_weekly_usage_count":0,"weekly_start_time":1781452800000,"weekly_end_time":1782057600000,"weekly_remains_time":221840796,"current_interval_status":1,"current_interval_remaining_percent":98,"current_weekly_status":1,"current_weekly_remaining_percent":67,"weekly_boost_permille":1500},{"start_time":1781798400000,"end_time":1781884800000,"remains_time":49040796,"current_interval_total_count":3,"current_interval_usage_count":0,"model_name":"video","current_weekly_total_count":21,"current_weekly_usage_count":0,"weekly_start_time":1781452800000,"weekly_end_time":1782057600000,"weekly_remains_time":221840796,"current_interval_status":1,"current_interval_remaining_percent":100,"current_weekly_status":1,"current_weekly_remaining_percent":100}],"base_resp":{"status_code":0,"status_msg":"success"}}"#; + + #[test] + fn builds_interval_and_weekly_metrics_from_token_plan_response() { + let resp: ApiResponse = serde_json::from_str(SAMPLE).unwrap(); + let metrics = build_metrics(resp.model_remains.as_deref().unwrap_or(&[])); + + // 2 models x 2 windows (interval + weekly) + assert_eq!(metrics.len(), 4); + + // general interval: 98% remaining -> 2% used, resets at end_time (2026) + assert_eq!(metrics[0].label, "general"); + assert_eq!(metrics[0].remaining_percent, 98.0); + assert_eq!(metrics[0].used_percent, 2.0); + assert!(metrics[0].resets_at.as_deref().unwrap().contains("2026")); + + // general weekly: 67% remaining -> 33% used + assert_eq!(metrics[1].label, "general·wk"); + assert_eq!(metrics[1].remaining_percent, 67.0); + assert_eq!(metrics[1].used_percent, 33.0); + + // video interval: 100% remaining + assert_eq!(metrics[2].label, "video"); + assert_eq!(metrics[2].remaining_percent, 100.0); + assert_eq!(metrics[2].used_percent, 0.0); + + // video weekly: 100% remaining + assert_eq!(metrics[3].label, "video·wk"); + assert_eq!(metrics[3].remaining_percent, 100.0); + } + + #[test] + fn flags_non_zero_status_code_as_api_error() { + let ok: ApiResponse = + serde_json::from_str(r#"{"base_resp":{"status_code":0,"status_msg":"success"}}"#) + .unwrap(); + assert!(!is_api_error(&ok)); + assert!(!is_auth_error(&ok)); + + let unauthorized: ApiResponse = serde_json::from_str( + r#"{"base_resp":{"status_code":1004,"status_msg":"unauthorized"}}"#, + ) + .unwrap(); + assert!(is_api_error(&unauthorized)); + assert!(is_auth_error(&unauthorized)); + } + + #[test] + fn omits_window_when_its_percent_is_absent() { + let resp: ApiResponse = serde_json::from_str( + r#"{"model_remains":[{"model_name":"general","current_interval_remaining_percent":50}],"base_resp":{"status_code":0}}"#, + ) + .unwrap(); + let metrics = build_metrics(resp.model_remains.as_deref().unwrap_or(&[])); + + // Only the interval window is present -> a single metric, no weekly row. + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].label, "general"); + assert_eq!(metrics[0].remaining_percent, 50.0); + } + + #[test] + fn skips_weekly_window_when_its_status_is_inactive() { + // Old plans without a weekly limit may still return weekly fields (e.g. + // percent 0), but current_weekly_status signals the window is inactive. + let resp: ApiResponse = serde_json::from_str( + r#"{"model_remains":[{"model_name":"general","current_interval_remaining_percent":80,"current_weekly_status":0,"current_weekly_remaining_percent":0}],"base_resp":{"status_code":0}}"#, + ) + .unwrap(); + let metrics = build_metrics(resp.model_remains.as_deref().unwrap_or(&[])); + + // Interval is active; weekly must be suppressed despite a percent being + // present, so it never reads as "0% left / 100% used". + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].label, "general"); + assert_eq!(metrics[0].remaining_percent, 80.0); + } + + #[test] + fn treats_seconds_and_millis_epochs_equivalently() { + // The seconds-vs-ms heuristic must scale a seconds-scale epoch up by + // 1000 so it matches the same instant expressed in milliseconds. + let seconds = epoch_ms_to_rfc3339(1_781_852_400).unwrap(); + let millis = epoch_ms_to_rfc3339(1_781_852_400_000).unwrap(); + assert_eq!(seconds, millis); + assert!(seconds.contains("2026")); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/mod.rs b/crates/tokscale-cli/src/commands/usage/mod.rs index 61ded35d5..65e9cc2dc 100644 --- a/crates/tokscale-cli/src/commands/usage/mod.rs +++ b/crates/tokscale-cli/src/commands/usage/mod.rs @@ -1,10 +1,15 @@ +#![cfg_attr(test, allow(dead_code))] + mod amp; mod claude; -mod codex; +pub mod codex; mod copilot; +mod grok; pub mod helpers; mod kimi; mod minimax; +mod minimax_tokenplan; +mod sakana; mod warp; mod zai; @@ -21,12 +26,253 @@ pub struct UsageMetric { pub resets_at: Option, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageResetCredits { + pub available_count: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub credits: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageResetCredit { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reset_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageCreditStatus { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub balance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub has_credits: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unlimited: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overage_limit_reached: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageSpendControl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub individual_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reached: Option, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UsageOutput { pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, pub plan: Option, pub email: Option, pub metrics: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reset_credits: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credit_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spend_control: Option, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageAccount { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default)] + pub is_active: bool, +} + +impl UsageAccount { + pub fn label_name(&self) -> Option<&str> { + self.label + .as_deref() + .map(str::trim) + .filter(|label| !label.is_empty()) + } + + pub fn short_id(&self) -> String { + let id = self.id.trim(); + if id.is_empty() { + return "unknown".to_string(); + } + + let char_count = id.chars().count(); + if char_count <= 12 { + return id.to_string(); + } + + let head: String = id.chars().take(6).collect(); + let tail: String = id + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{head}...{tail}") + } + + pub fn display_name(&self) -> String { + self.label_name() + .map(str::to_string) + .unwrap_or_else(|| format!("Account {}", self.short_id())) + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UsageFetchDiagnostic { + pub provider: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, + #[serde(default)] + pub kind: UsageFetchDiagnosticKind, + #[serde(default)] + pub severity: UsageFetchDiagnosticSeverity, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageFetchDiagnosticKind { + #[default] + FetchFailed, + ImportCurrentLoginFailed, + ProviderPanicked, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UsageFetchDiagnosticSeverity { + Info, + Warning, + #[default] + Error, +} + +impl UsageFetchDiagnostic { + pub fn new( + provider: impl Into, + account: Option, + message: impl Into, + ) -> Self { + Self::with_kind( + provider, + account, + UsageFetchDiagnosticKind::FetchFailed, + UsageFetchDiagnosticSeverity::Error, + message, + ) + } + + pub fn with_kind( + provider: impl Into, + account: Option, + kind: UsageFetchDiagnosticKind, + severity: UsageFetchDiagnosticSeverity, + message: impl Into, + ) -> Self { + Self { + provider: provider.into(), + account, + kind, + severity, + message: message.into(), + } + } + + pub fn display_name(&self) -> String { + match &self.account { + Some(account) => format!("{} ({})", self.provider, account.display_name()), + None => self.provider.clone(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct UsageFetchReport { + pub outputs: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsageFetchIntent { + #[allow(dead_code)] + CliReadOnly, + TuiSurface, +} + +impl UsageFetchReport { + fn from_outputs(outputs: Vec) -> Self { + Self { + outputs, + diagnostics: Vec::new(), + } + } + + fn from_error( + provider: &'static str, + account: Option, + error: anyhow::Error, + ) -> Self { + Self { + outputs: Vec::new(), + diagnostics: vec![UsageFetchDiagnostic::new( + provider, + account, + error.to_string(), + )], + } + } + + fn extend(&mut self, other: UsageFetchReport) { + self.outputs.extend(other.outputs); + self.diagnostics.extend(other.diagnostics); + } +} + +impl UsageOutput { + pub fn account_display_name(&self) -> Option { + let account = self.account.as_ref()?; + + if let Some(label) = account.label_name() { + return Some(label.to_string()); + } + + if let Some(email) = self + .email + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return Some(email.to_string()); + } + + Some(account.display_name()) + } + + pub fn display_name(&self) -> String { + match &self.account { + Some(_) => format!( + "{} ({})", + self.provider, + self.account_display_name().unwrap_or_default() + ), + None => self.provider.clone(), + } + } } // ── Cache ── @@ -78,41 +324,209 @@ pub fn load_cache() -> Option> { // ── Public API ── -type UsageProvider = (&'static str, fn() -> bool, fn() -> Result); +#[derive(Clone, Copy)] +enum Fetch { + Single(fn() -> Result), + Multi(fn() -> Result>), +} + +impl Fetch { + fn call(self) -> Result> { + match self { + Fetch::Single(fetch) => fetch().map(|output| vec![output]), + Fetch::Multi(fetch) => fetch(), + } + } +} + +type UsageProvider = (&'static str, fn() -> bool, Fetch); + +/// A provider that is active (has credentials) but whose fetch failed. +/// +/// `name` is the human-facing provider label and `error` is the formatted +/// error message (e.g. sakana's "refresh SAKANA_SESSION_COOKIE" guidance). +#[derive(Debug, Clone)] +pub struct ProviderError { + pub name: &'static str, + pub error: String, +} +/// Backwards-compatible entry point: returns only successful provider outputs. +/// +/// Per-provider errors are silently discarded here. Callers that need to make +/// failures visible (e.g. the CLI `run`) should use [`fetch_all_with_errors`]. +/// +/// Used by the TUI dashboard (non-test builds only); the TUI test build stubs +/// the fetch out, so allow it to be unused there. +#[allow(dead_code)] pub fn fetch_all() -> Vec { - let providers: Vec = vec![ - ("Claude", claude::has_credentials, claude::fetch), - ("Codex", codex::has_credentials, codex::fetch), - ("Z.ai", zai::has_credentials, zai::fetch), - ("Amp", amp::has_credentials, amp::fetch), - ("Copilot", copilot::has_credentials, copilot::fetch), - ("Kimi", kimi::has_credentials, kimi::fetch), - ("MiniMax", minimax::has_credentials, minimax::fetch), - ("Warp/Oz", warp::has_credentials, warp::fetch), - ]; - - let active: Vec<_> = providers.into_iter().filter(|(_, has, _)| has()).collect(); + fetch_all_with_errors().0 +} + +/// Fetch usage for every active provider in parallel, returning both the +/// successful outputs and the per-provider errors. +/// +/// Previously a provider whose `fetch` returned `Err` (notably a stale/expired +/// session-cookie auth error) was silently dropped: `has_credentials()` reports +/// the provider as active, yet it just vanished from the output. This collects +/// those errors so the caller can surface them to the user instead. +pub fn fetch_all_with_errors() -> (Vec, Vec) { + let active: Vec<_> = usage_providers(Fetch::Multi(codex::fetch_all)) + .into_iter() + .filter(|(_, has, _)| has()) + .collect(); if active.is_empty() { - return vec![]; + return (vec![], vec![]); } - std::thread::scope(|s| { - active + let results = std::thread::scope(|s| { + let handles: Vec<_> = active .into_iter() - .map(|(_, _, fetch)| s.spawn(move || fetch().ok())) + .map(|(name, _, fetch)| s.spawn(move || (name, fetch.call()))) + .collect(); + + handles + .into_iter() + .filter_map(|handle| { + // A panicked provider thread should not take down the whole + // command; skip it (a join error has no message to surface). + handle.join().ok() + }) .collect::>() + }); + + partition_results(results) +} + +fn usage_providers(codex_fetch: Fetch) -> Vec { + vec![ + ( + "Claude", + claude::has_credentials, + Fetch::Single(claude::fetch), + ), + ("Codex", codex::has_credentials, codex_fetch), + ("Z.ai", zai::has_credentials, Fetch::Single(zai::fetch)), + ("Amp", amp::has_credentials, Fetch::Single(amp::fetch)), + ( + "Copilot", + copilot::has_credentials, + Fetch::Single(copilot::fetch), + ), + ( + "Grok Build", + grok::has_credentials, + Fetch::Single(grok::fetch), + ), + ("Kimi", kimi::has_credentials, Fetch::Single(kimi::fetch)), + ( + "MiniMax", + minimax::has_credentials, + Fetch::Single(minimax::fetch), + ), + ( + "MiniMax Token Plan", + minimax_tokenplan::has_credentials, + Fetch::Multi(minimax_tokenplan::fetch_all), + ), + ("Warp/Oz", warp::has_credentials, Fetch::Single(warp::fetch)), + ( + "Sakana", + sakana::has_credentials, + Fetch::Single(sakana::fetch), + ), + ] +} + +fn fetch_provider_report( + provider: &'static str, + result: Result>, +) -> UsageFetchReport { + match result { + Ok(outputs) => UsageFetchReport::from_outputs(outputs), + Err(error) => UsageFetchReport::from_error(provider, None, error), + } +} + +pub fn fetch_all_report_with_intent(intent: UsageFetchIntent) -> UsageFetchReport { + let codex_fetch = match intent { + UsageFetchIntent::CliReadOnly => codex::fetch_all_report, + UsageFetchIntent::TuiSurface => codex::fetch_all_report_importing_current_auth, + }; + fetch_all_report_with_codex(codex_fetch) +} + +fn fetch_all_report_with_codex(codex_fetch: fn() -> UsageFetchReport) -> UsageFetchReport { + let active: Vec<_> = usage_providers(Fetch::Multi(codex::fetch_all)) + .into_iter() + .filter(|(_, has, _)| has()) + .collect(); + + if active.is_empty() { + return UsageFetchReport::default(); + } + + std::thread::scope(|scope| { + let handles = active .into_iter() - .filter_map(|h| h.join().ok().flatten()) - .collect() + .map(|(provider, _, fetch)| { + let handle = if provider == "Codex" { + scope.spawn(codex_fetch) + } else { + scope.spawn(move || fetch_provider_report(provider, fetch.call())) + }; + (provider, handle) + }) + .collect::>(); + + let mut report = UsageFetchReport::default(); + for (provider, handle) in handles { + match handle.join() { + Ok(provider_report) => report.extend(provider_report), + Err(_) => report.diagnostics.push(UsageFetchDiagnostic::with_kind( + provider, + None, + UsageFetchDiagnosticKind::ProviderPanicked, + UsageFetchDiagnosticSeverity::Error, + "usage fetch worker panicked", + )), + } + } + report }) } +/// Split per-provider fetch results into (successful outputs, errors). +/// +/// An active provider returning `Err` becomes a [`ProviderError`] rather than +/// being silently dropped. +fn partition_results( + results: Vec<(&'static str, Result>)>, +) -> (Vec, Vec) { + let mut outputs = Vec::new(); + let mut errors = Vec::new(); + for (name, result) in results { + match result { + Ok(mut provider_outputs) => outputs.append(&mut provider_outputs), + Err(err) => errors.push(ProviderError { + name, + error: err.to_string(), + }), + } + } + (outputs, errors) +} + // ── Light-mode rendering ── const BAR_WIDTH: usize = 12; -const CARD_WIDTH: usize = 62; +const METRIC_LABEL_WIDTH: usize = 14; +const METRIC_REMAINING_WIDTH: usize = 11; +const METRIC_BAR_WIDTH: usize = BAR_WIDTH + 2; +const METRIC_RESET_WIDTH: usize = 24; +const CARD_WIDTH: usize = + 1 + METRIC_LABEL_WIDTH + METRIC_REMAINING_WIDTH + METRIC_BAR_WIDTH + METRIC_RESET_WIDTH; fn truncate(s: &str, max_len: usize) -> String { if s.chars().count() <= max_len { @@ -125,7 +539,11 @@ fn truncate(s: &str, max_len: usize) -> String { fn render_light(output: &UsageOutput) { println!("╭{}╮", "─".repeat(CARD_WIDTH)); // Provider header - println!("│ {: Result<()> { - let outputs = fetch_all(); + let (outputs, errors) = fetch_all_with_errors(); if json { + // Keep stdout pure JSON: do NOT emit provider warnings here, since they + // would corrupt downstream `--json` consumers that read stderr too. println!("{}", serde_json::to_string_pretty(&outputs)?); } else { for o in &outputs { render_light(o); } + // Surface active-but-failed providers (e.g. an expired session cookie) + // so they don't silently vanish from the output. One concise line per + // failing provider, on stderr to keep stdout clean. + for err in &errors { + eprintln!("{}: {} — skipped", err.name, err.error); + } } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn usage_output_display_name_includes_account_label() { + let output = UsageOutput { + provider: "Codex".to_string(), + account: Some(UsageAccount { + id: "acct_123".to_string(), + label: Some("work".to_string()), + is_active: true, + }), + plan: None, + email: None, + metrics: Vec::new(), + reset_credits: None, + credit_status: None, + spend_control: None, + }; + + assert_eq!(output.display_name(), "Codex (work)"); + } + + #[test] + fn usage_output_display_name_prefers_email_over_account_id() { + let output = UsageOutput { + provider: "Codex".to_string(), + account: Some(UsageAccount { + id: "acct_123".to_string(), + label: Some(" ".to_string()), + is_active: false, + }), + plan: None, + email: Some("user@example.com".to_string()), + metrics: Vec::new(), + reset_credits: None, + credit_status: None, + spend_control: None, + }; + + assert_eq!(output.display_name(), "Codex (user@example.com)"); + } + + #[test] + fn usage_output_display_name_masks_long_account_id() { + let output = UsageOutput { + provider: "Codex".to_string(), + account: Some(UsageAccount { + id: "123e4567-e89b-12d3-a456-426614174000".to_string(), + label: None, + is_active: false, + }), + plan: None, + email: None, + metrics: Vec::new(), + reset_credits: None, + credit_status: None, + spend_control: None, + }; + + assert_eq!(output.display_name(), "Codex (Account 123e45...4000)"); + } + + fn sample_output(provider: &str) -> UsageOutput { + UsageOutput { + provider: provider.to_string(), + account: None, + plan: None, + email: None, + metrics: Vec::new(), + reset_credits: None, + credit_status: None, + spend_control: None, + } + } + + #[test] + fn partition_results_surfaces_provider_errors_instead_of_dropping_them() { + let results: Vec<(&'static str, Result>)> = vec![ + ("Claude", Ok(vec![sample_output("Claude")])), + ( + "Sakana", + Err(anyhow::anyhow!( + "Sakana session expired or invalid. Refresh SAKANA_SESSION_COOKIE." + )), + ), + ( + "Codex", + Ok(vec![sample_output("Codex"), sample_output("Codex")]), + ), + ]; + + let (outputs, errors) = partition_results(results); + + // Successful providers are preserved (including a Multi provider's + // several outputs), in order. + assert_eq!(outputs.len(), 3); + assert_eq!(outputs[0].provider, "Claude"); + assert_eq!(outputs[1].provider, "Codex"); + assert_eq!(outputs[2].provider, "Codex"); + + // The failing provider's error is surfaced, not silently discarded. + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].name, "Sakana"); + assert!( + errors[0].error.contains("SAKANA_SESSION_COOKIE"), + "expected the auth-refresh guidance to be preserved, got: {}", + errors[0].error + ); + } + + #[test] + fn partition_results_reports_no_errors_when_all_succeed() { + let results: Vec<(&'static str, Result>)> = + vec![("Claude", Ok(vec![sample_output("Claude")]))]; + + let (outputs, errors) = partition_results(results); + + assert_eq!(outputs.len(), 1); + assert!(errors.is_empty()); + } + + #[test] + fn usage_output_deserializes_legacy_json_without_account() -> Result<()> { + let output: UsageOutput = serde_json::from_str( + r#"{ + "provider": "Codex", + "plan": null, + "email": null, + "metrics": [] + }"#, + )?; + + assert!(output.account.is_none()); + assert_eq!(output.display_name(), "Codex"); + Ok(()) + } +} diff --git a/crates/tokscale-cli/src/commands/usage/sakana.rs b/crates/tokscale-cli/src/commands/usage/sakana.rs new file mode 100644 index 000000000..519cfbd34 --- /dev/null +++ b/crates/tokscale-cli/src/commands/usage/sakana.rs @@ -0,0 +1,870 @@ +// ── Sakana (Fugu) subscription-usage provider ── +// +// IMPORTANT — read before touching this file: +// +// Sakana (Fugu) exposes NO public usage/quota API. This was investigated and +// confirmed: there is no documented REST endpoint and no OAuth-scoped usage +// route comparable to Claude's `/api/oauth/usage` or Z.ai's quota endpoint. +// The ONLY source of subscription-usage data is the authenticated billing +// console at https://console.sakana.ai/billing. +// +// That console is a Next.js app, but the rendered usage values ARE present in +// the served HTML of a plain authenticated GET (verified against the real +// page). So this provider fetches that HTML with the user's session cookie and +// scrapes the values out of it. +// +// Consequences you MUST keep in mind: +// * This is a best-effort, COOKIE-AUTH, LAYOUT-COUPLED scraper. If Sakana +// restructures the billing page, the parser silently degrades (fields +// become None) or fails to find the markers. It is not a stable contract. +// * The session cookie EXPIRES. When it does, the GET returns a login page +// (or 401/403). We detect that and tell the user to refresh the cookie — +// we do NOT panic and do NOT emit a bogus parse. +// * The numbers reported here are rolling QUOTA windows (5-hour / weekly +// "% used"), NOT dollar spend. Sakana subscription billing is a flat +// monthly fee ($NN/mo), so there is no per-request spend to report; the +// monthly price and next-renewal date are surfaced as plan metadata only. +// +// Parsing is done with plain `str` scanning (no `regex` dependency in this +// crate) but follows the exact validated token formats documented inline. + +use anyhow::Result; + +use super::{UsageMetric, UsageOutput}; + +const BILLING_URL: &str = "https://console.sakana.ai/billing"; +const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ +AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"; + +// ── Credential discovery ── + +/// Returns the session cookie string if one is available, else None. +/// +/// Source order: +/// 1. env var `SAKANA_SESSION_COOKIE` +/// 2. file `/sakana-session` (raw cookie string, trimmed) +/// +/// The config dir is resolved via the canonical `crate::paths::get_config_dir()` +/// so `TOKSCALE_CONFIG_DIR` / XDG overrides are honored (matching every other +/// provider, e.g. `codex.rs`), instead of hardcoding `~/.config/tokscale`. +/// +/// Empty / whitespace-only values are treated as absent. +fn session_cookie() -> Option { + if let Ok(val) = std::env::var("SAKANA_SESSION_COOKIE") { + let trimmed = val.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + let path = crate::paths::get_config_dir().join("sakana-session"); + if let Ok(content) = std::fs::read_to_string(&path) { + let trimmed = content.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + + None +} + +pub fn has_credentials() -> bool { + session_cookie().is_some() +} + +// ── Parsed shape ── + +#[derive(Debug, Default, PartialEq)] +struct ParsedBilling { + plan: Option, + monthly_price: Option, + next_renewal: Option, + windows: Vec, +} + +#[derive(Debug, PartialEq)] +struct ParsedWindow { + label: String, + used_percent: f64, + resets_at: Option, +} + +// ── Login-page detection ── + +/// Heuristic: does this HTML look like a logged-out / login page rather than the +/// authenticated billing console? +/// +/// We key off marker ABSENCE rather than sign-in strings on purpose: a valid +/// logged-in page can legitimately contain "/login" / "Sign in" references (auth +/// nav, callbacks), which would otherwise false-flag a working session. +/// +/// However, the bare substrings "Billing" + ("/mo" | "% used") are too weak: an +/// error page, a redirect shell, or a partially-rendered page can carry those +/// tokens (e.g. inside script/RSC noise) yet contain no real billing data, +/// producing a bogus empty card instead of a needs-auth signal. So we require a +/// STRONGER positive signal — a real window label (`>5-hour<` / `>Weekly<`) or a +/// concrete `$NN/mo` price match — before trusting the page. A price-only shell +/// (price present but NO quota windows) is caught downstream in `parse_billing`, +/// which treats a windowless parse as not logged in. This stays conservative: a +/// genuine billing page always renders quota windows. +fn looks_logged_out(html: &str) -> bool { + if !html.contains("Billing") { + return true; + } + let has_real_window = !find_window_label_positions(html).is_empty(); + let has_real_price = find_monthly_price(html).is_some(); + !(has_real_window || has_real_price) +} + +// ── Parsing helpers (plain str, no regex) ── + +/// Find the monthly price for the pattern `\$(\d+)\s*/\s*mo`, e.g. `$20 / mo`, +/// `$20/mo`. Returns (price, byte index of the `$` that matched). +fn find_monthly_price(html: &str) -> Option<(u32, usize)> { + let bytes = html.as_bytes(); + let mut search_from = 0usize; + while let Some(rel) = html[search_from..].find('$') { + let dollar_idx = search_from + rel; + let mut i = dollar_idx + 1; + // digits + let digits_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i == digits_start { + search_from = dollar_idx + 1; + continue; + } + let digits = &html[digits_start..i]; + // optional whitespace + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + // slash + if i < bytes.len() && bytes[i] == b'/' { + i += 1; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if html[i..].starts_with("mo") { + if let Ok(price) = digits.parse::() { + return Some((price, dollar_idx)); + } + } + } + search_from = dollar_idx + 1; + } + None +} + +/// Determine the active plan tier. The active tier is the (Standard|Pro|Max) +/// token NEAREST-PRECEDING the `$NN/mo` price match — the upgrade buttons also +/// contain "Pro"/"Max", but the active tier is the one rendered with the price. +/// If we cannot find one before the price, fall back to the first +/// (Standard|Pro|Max) occurrence after the word "Billing". +fn find_plan(html: &str, price_idx: Option) -> Option { + const TIERS: [&str; 3] = ["Standard", "Pro", "Max"]; + + // Nearest-preceding the price. + if let Some(idx) = price_idx { + let prefix = &html[..idx]; + let mut best: Option<(usize, &str)> = None; + for tier in TIERS { + if let Some(pos) = prefix.rfind(tier) { + match best { + Some((bp, _)) if bp >= pos => {} + _ => best = Some((pos, tier)), + } + } + } + if let Some((_, tier)) = best { + return Some(tier.to_string()); + } + } + + // Fallback: first tier after "Billing". + let start = html + .find("Billing") + .map(|i| i + "Billing".len()) + .unwrap_or(0); + let region = &html[start..]; + let mut best: Option<(usize, &str)> = None; + for tier in TIERS { + if let Some(pos) = region.find(tier) { + match best { + Some((bp, _)) if bp <= pos => {} + _ => best = Some((pos, tier)), + } + } + } + best.map(|(_, tier)| tier.to_string()) +} + +/// Collect all `(\d+(\.\d+)?)% used` percentages in document order. +/// +/// The number immediately preceding `%` is parsed in full as an `f64`, +/// INCLUDING a single decimal point (e.g. `7.5% used` -> 7.5). A previous +/// version walked back over at most 3 *digits*, which silently truncated +/// decimals — `7.5%` captured only `5` and reported `5.0`, confidently wrong. +/// Values are clamped to the sane percentage range `0..=100`. +fn find_used_percents(html: &str) -> Vec { + const NEEDLE: &str = "% used"; + let bytes = html.as_bytes(); + let mut out = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = html[search_from..].find(NEEDLE) { + let pct_sign = search_from + rel; // index of '%' + + // Walk backwards over the contiguous numeric run immediately preceding + // '%': ASCII digits plus a single decimal point. Stop at the first + // non-numeric byte (or a second '.'). + let mut start = pct_sign; + let mut seen_dot = false; + while start > 0 { + let b = bytes[start - 1]; + if b.is_ascii_digit() { + start -= 1; + } else if b == b'.' && !seen_dot { + seen_dot = true; + start -= 1; + } else { + break; + } + } + + // Reject a run that is just "." (no digits) or has a leading/trailing + // dot that won't parse as f64; `parse` enforces the rest. + let token = &html[start..pct_sign]; + if token.bytes().any(|b| b.is_ascii_digit()) { + if let Ok(v) = token.parse::() { + out.push(v.clamp(0.0, 100.0)); + } + } + search_from = pct_sign + NEEDLE.len(); + } + out +} + +/// Collect window label positions matching `>(5-hour|Weekly)<`, sorted by +/// document order. Returns (byte index of the label, label). +fn find_window_label_positions(html: &str) -> Vec<(usize, String)> { + const LABELS: [&str; 2] = ["5-hour", "Weekly"]; + let mut found: Vec<(usize, String)> = Vec::new(); + for label in LABELS { + let needle = format!(">{label}<"); + let mut search_from = 0usize; + while let Some(rel) = html[search_from..].find(&needle) { + let idx = search_from + rel; + found.push((idx, label.to_string())); + search_from = idx + needle.len(); + } + } + found.sort_by_key(|(idx, _)| *idx); + found +} + +/// Build one window per on-page label (`5-hour` / `Weekly`), binding the +/// percentage and reset time that STRUCTURALLY belong to that label — the first +/// of each within the label's section (from the label up to the next label) — +/// rather than collecting every `% used` in the document. +/// +/// This matters because the served HTML embeds each usage value MORE THAN ONCE +/// (the rendered card markup AND serialized RSC data), so a global +/// "collect-all-percents, pair-by-index" approach invents phantom windows and +/// mis-pairs percentages. The window labels appear only in the rendered cards, +/// so anchoring on them is the reliable structural key. +fn parse_windows(html: &str) -> Vec { + let labels = find_window_label_positions(html); + if !labels.is_empty() { + let mut windows = Vec::with_capacity(labels.len()); + for (k, (pos, label)) in labels.iter().enumerate() { + let end = labels.get(k + 1).map(|(p, _)| *p).unwrap_or(html.len()); + let segment = &html[*pos..end]; + if let Some(&pct) = find_used_percents(segment).first() { + windows.push(ParsedWindow { + label: label.clone(), + used_percent: pct, + resets_at: find_reset_times(segment).into_iter().next(), + }); + } + } + return windows; + } + + // Degraded fallback: no labels found at all. Emit at most the known number + // of windows from the leading percentages, in document order, so a label + // markup change still surfaces *something* without inventing phantoms. + const FALLBACK_LABELS: [&str; 2] = ["5-hour", "Weekly"]; + find_used_percents(html) + .into_iter() + .take(FALLBACK_LABELS.len()) + .enumerate() + .map(|(i, pct)| ParsedWindow { + label: FALLBACK_LABELS[i].to_string(), + used_percent: pct, + resets_at: None, + }) + .collect() +} + +/// Collect reset times matching +/// `Resets on\s+([A-Z][a-z]+ \d{1,2}, \d{4} at \d{1,2}:\d{2} [AP]M)` in order. +fn find_reset_times(html: &str) -> Vec { + const PREFIX: &str = "Resets on"; + let mut out = Vec::new(); + let mut search_from = 0usize; + while let Some(rel) = html[search_from..].find(PREFIX) { + let idx = search_from + rel; + let after = &html[idx + PREFIX.len()..]; + let trimmed = after.trim_start(); + if let Some(reset) = parse_reset_value(trimmed) { + out.push(reset); + } + search_from = idx + PREFIX.len(); + } + out +} + +/// Parse `Month D, YYYY at H:MM AM/PM` from the start of `s`. +fn parse_reset_value(s: &str) -> Option { + // Month + let (month, rest) = take_capitalized_word(s)?; + let rest = rest.strip_prefix(' ')?; + let (_day, rest) = take_digits(rest, 1, 2)?; + let rest = rest.strip_prefix(", ")?; + let (_year, rest) = take_digits(rest, 4, 4)?; + let rest = rest.strip_prefix(" at ")?; + let (_hour, rest) = take_digits(rest, 1, 2)?; + let rest = rest.strip_prefix(':')?; + let (_min, rest) = take_digits(rest, 2, 2)?; + let rest = rest.strip_prefix(' ')?; + let meridiem = if rest.starts_with("AM") { + "AM" + } else if rest.starts_with("PM") { + "PM" + } else { + return None; + }; + // Reconstruct the exact matched substring. + let consumed = s.len() - rest.len() + meridiem.len(); + let _ = month; + Some(s[..consumed].to_string()) +} + +/// Next renewal: `Next renewal:?\s*([A-Z][a-z]+ \d{1,2}, \d{4})`. +fn find_next_renewal(html: &str) -> Option { + const PREFIX: &str = "Next renewal"; + let idx = html.find(PREFIX)?; + let mut after = &html[idx + PREFIX.len()..]; + after = after.strip_prefix(':').unwrap_or(after); + let after = after.trim_start(); + parse_date_value(after) +} + +/// Parse `Month D, YYYY` from the start of `s`. +fn parse_date_value(s: &str) -> Option { + let (_month, rest) = take_capitalized_word(s)?; + let rest = rest.strip_prefix(' ')?; + let (_day, rest) = take_digits(rest, 1, 2)?; + let rest = rest.strip_prefix(", ")?; + let (_year, rest) = take_digits(rest, 4, 4)?; + let consumed = s.len() - rest.len(); + Some(s[..consumed].to_string()) +} + +/// Take a leading `[A-Z][a-z]+` word; return (word, remainder). +fn take_capitalized_word(s: &str) -> Option<(&str, &str)> { + let bytes = s.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_uppercase() { + return None; + } + let mut i = 1; + while i < bytes.len() && bytes[i].is_ascii_lowercase() { + i += 1; + } + if i < 2 { + return None; + } + Some((&s[..i], &s[i..])) +} + +/// Take between `min` and `max` leading ASCII digits; return (digits, remainder). +fn take_digits(s: &str, min: usize, max: usize) -> Option<(&str, &str)> { + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() && i < max && bytes[i].is_ascii_digit() { + i += 1; + } + if i < min { + return None; + } + Some((&s[..i], &s[i..])) +} + +// ── Top-level parser ── + +/// Parse the served billing HTML into a structured shape. Returns Err only when +/// the page looks logged-out / the session is invalid. Missing individual fields +/// degrade to None / empty rather than failing the whole parse. +fn parse_billing(html: &str) -> Result { + if looks_logged_out(html) { + anyhow::bail!("NEEDS_AUTH"); + } + + let price = find_monthly_price(html); + let monthly_price = price.map(|(p, _)| p); + let price_idx = price.map(|(_, i)| i); + let plan = find_plan(html, price_idx); + let next_renewal = find_next_renewal(html); + let windows = parse_windows(html); + + // Defense in depth: the real billing console ALWAYS renders quota windows. + // A parse that recovered no windows is not a usable billing page — even when + // a price/plan was scraped. An expired-cookie/error shell can legitimately + // carry `Billing` + a spaced price like `$20 / mo` (so `find_monthly_price` + // succeeds and `looks_logged_out` is satisfied) while containing zero quota + // windows. Accepting that would emit a Sakana result with a plan and zero + // metrics instead of asking the user to refresh the cookie. Require actual + // quota-window data before trusting the page. + if windows.is_empty() { + anyhow::bail!("NEEDS_AUTH"); + } + + Ok(ParsedBilling { + plan, + monthly_price, + next_renewal, + windows, + }) +} + +// ── Output assembly ── + +fn build_output(parsed: ParsedBilling) -> UsageOutput { + let metrics = parsed + .windows + .into_iter() + .map(|w| UsageMetric { + label: w.label, + used_percent: w.used_percent, + remaining_percent: 100.0 - w.used_percent, + remaining_label: None, + resets_at: w.resets_at, + }) + .collect(); + + // Surface monthly price + next renewal as plan metadata (the struct has no + // dedicated billing fields, and these are flat-fee subscription details, not + // quota windows). + let plan = match (parsed.plan, parsed.monthly_price, parsed.next_renewal) { + (Some(tier), Some(price), Some(renew)) => { + Some(format!("{tier} (${price}/mo, renews {renew})")) + } + (Some(tier), Some(price), None) => Some(format!("{tier} (${price}/mo)")), + (Some(tier), None, Some(renew)) => Some(format!("{tier} (renews {renew})")), + (Some(tier), None, None) => Some(tier), + (None, Some(price), _) => Some(format!("${price}/mo")), + (None, None, _) => None, + }; + + UsageOutput { + provider: "Sakana".into(), + account: None, + plan, + email: None, + metrics, + reset_credits: None, + credit_status: None, + spend_control: None, + } +} + +async fn fetch_billing_html(client: &reqwest::Client, cookie: &str) -> Result { + let resp = client + .get(BILLING_URL) + .header("Cookie", cookie) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html") + .send() + .await?; + let status = resp.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + anyhow::bail!("NEEDS_AUTH"); + } + if !status.is_success() { + anyhow::bail!("Sakana billing request failed (HTTP {status})"); + } + Ok(resp.text().await?) +} + +pub fn fetch() -> Result { + let cookie = session_cookie().ok_or_else(|| { + anyhow::anyhow!( + "No Sakana session cookie. Set SAKANA_SESSION_COOKIE or write a \ + `sakana-session` file in the tokscale config dir." + ) + })?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::limited(10)) + .build()?; + + let html = fetch_billing_html(&client, &cookie).await.map_err(|e| { + if e.to_string().contains("NEEDS_AUTH") { + anyhow::anyhow!( + "Sakana session expired or invalid. Refresh SAKANA_SESSION_COOKIE \ + (re-copy the __Secure-authjs.session-token cookie from \ + console.sakana.ai)." + ) + } else { + e + } + })?; + + let parsed = parse_billing(&html).map_err(|e| { + if e.to_string().contains("NEEDS_AUTH") { + anyhow::anyhow!( + "Sakana session expired or invalid (login page returned). Refresh \ + SAKANA_SESSION_COOKIE from console.sakana.ai." + ) + } else { + e + } + })?; + + Ok(build_output(parsed)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A representative valid billing fixture using the exact validated token + // formats. Synthetic — contains NO real cookies/tokens. + const VALID_STANDARD: &str = r#" + + +
+ Standard + $20 / mo + + +
+
+ 5-hour + 55% used + Resets on June 22, 2026 at 9:58 AM +
+
+ Weekly + 19% used + Resets on June 28, 2026 at 12:00 PM +
+
Next renewal: July 22, 2026
+ +"#; + + #[test] + fn parses_valid_standard_billing() { + let parsed = parse_billing(VALID_STANDARD).expect("should parse"); + assert_eq!(parsed.plan.as_deref(), Some("Standard")); + assert_eq!(parsed.monthly_price, Some(20)); + assert_eq!(parsed.next_renewal.as_deref(), Some("July 22, 2026")); + assert_eq!(parsed.windows.len(), 2); + + assert_eq!(parsed.windows[0].label, "5-hour"); + assert_eq!(parsed.windows[0].used_percent, 55.0); + assert_eq!( + parsed.windows[0].resets_at.as_deref(), + Some("June 22, 2026 at 9:58 AM") + ); + + assert_eq!(parsed.windows[1].label, "Weekly"); + assert_eq!(parsed.windows[1].used_percent, 19.0); + assert_eq!( + parsed.windows[1].resets_at.as_deref(), + Some("June 28, 2026 at 12:00 PM") + ); + } + + #[test] + fn build_output_shapes_metrics_and_plan() { + let parsed = parse_billing(VALID_STANDARD).unwrap(); + let out = build_output(parsed); + assert_eq!(out.provider, "Sakana"); + assert_eq!( + out.plan.as_deref(), + Some("Standard ($20/mo, renews July 22, 2026)") + ); + assert_eq!(out.metrics.len(), 2); + assert_eq!(out.metrics[0].label, "5-hour"); + assert_eq!(out.metrics[0].used_percent, 55.0); + assert_eq!(out.metrics[0].remaining_percent, 45.0); + assert_eq!(out.metrics[1].label, "Weekly"); + assert_eq!(out.metrics[1].used_percent, 19.0); + } + + // Pro tier, where the "Upgrade to Max" button also contains a tier word. + // The active tier ("Pro") is the one rendered nearest-preceding the price. + const VALID_PRO: &str = r#" + + +
+ Pro + $100 / mo + +
+
+ 5-hour + 8% used + Resets on June 22, 2026 at 3:15 PM +
+
+ Weekly + 72% used + Resets on June 29, 2026 at 1:00 AM +
+
Next renewal: July 22, 2026
+ +"#; + + #[test] + fn disambiguates_active_pro_tier_from_upgrade_button() { + let parsed = parse_billing(VALID_PRO).expect("should parse"); + // "Max" appears in the upgrade button AFTER the price; active tier is + // "Pro", which is nearest-preceding the $100/mo price. + assert_eq!(parsed.plan.as_deref(), Some("Pro")); + assert_eq!(parsed.monthly_price, Some(100)); + assert_eq!(parsed.windows.len(), 2); + assert_eq!(parsed.windows[0].used_percent, 8.0); + assert_eq!(parsed.windows[1].used_percent, 72.0); + } + + // Logged-out / login page: billing markers absent, sign-in present. + const LOGIN_PAGE: &str = r#" + +

Sign in to Sakana

+ Continue with Google + +"#; + + #[test] + fn login_page_returns_needs_auth_error() { + let err = parse_billing(LOGIN_PAGE).expect_err("login page must error"); + assert!( + err.to_string().contains("NEEDS_AUTH"), + "expected NEEDS_AUTH, got: {err}" + ); + } + + // Missing renewal + reset; percentages must still parse. + const MISSING_META: &str = r#" + + +
+ Standard + $20 / mo +
+
+ 5-hour + 33% used +
+
+ Weekly + 5% used +
+ +"#; + + #[test] + fn graceful_degradation_when_meta_missing() { + let parsed = parse_billing(MISSING_META).expect("should still parse percentages"); + assert_eq!(parsed.plan.as_deref(), Some("Standard")); + assert_eq!(parsed.monthly_price, Some(20)); + assert_eq!(parsed.next_renewal, None); + assert_eq!(parsed.windows.len(), 2); + assert_eq!(parsed.windows[0].label, "5-hour"); + assert_eq!(parsed.windows[0].used_percent, 33.0); + assert_eq!(parsed.windows[0].resets_at, None); + assert_eq!(parsed.windows[1].label, "Weekly"); + assert_eq!(parsed.windows[1].used_percent, 5.0); + assert_eq!(parsed.windows[1].resets_at, None); + } + + // Mirrors the REAL served HTML: the usage values are ALSO embedded in + // serialized RSC data (extra "% used" tokens, some preceding the rendered + // cards, with NO window label). The parser must anchor on the labels and + // emit exactly 2 correctly-paired windows — not invent "Window 3/4" or + // mis-pair the weekly value. Regression test for the bug caught by a live run. + const DUPLICATED_RSC: &str = r#" + + + +
Standard$20 / mo
+
5-hour55% usedResets on June 22, 2026 at 9:58 AM
+
Weekly19% usedResets on June 29, 2026 at 12:00 AM
+ + +"#; + + #[test] + fn ignores_duplicated_rsc_percentages() { + let parsed = parse_billing(DUPLICATED_RSC).expect("should parse"); + assert_eq!( + parsed.windows.len(), + 2, + "must bind to the 2 labels, not collect every % used" + ); + assert_eq!(parsed.windows[0].label, "5-hour"); + assert_eq!(parsed.windows[0].used_percent, 55.0); + assert_eq!( + parsed.windows[0].resets_at.as_deref(), + Some("June 22, 2026 at 9:58 AM") + ); + assert_eq!(parsed.windows[1].label, "Weekly"); + assert_eq!(parsed.windows[1].used_percent, 19.0); + assert_eq!( + parsed.windows[1].resets_at.as_deref(), + Some("June 29, 2026 at 12:00 AM") + ); + } + + #[test] + fn percents_parse_in_document_order() { + let pcts = find_used_percents("a 55% used b 19% used c 100% used"); + assert_eq!(pcts, vec![55.0, 19.0, 100.0]); + } + + #[test] + fn decimal_percents_parse_in_full() { + // Regression: the old digit-walk captured at most 3 trailing digits and + // dropped the decimal point, so "7.5%" reported 5.0. Full f64 parse now. + assert_eq!(find_used_percents("7.5% used"), vec![7.5]); + // Integer case still works. + assert_eq!(find_used_percents("42% used"), vec![42.0]); + // Mixed decimal + integer, document order, including a >3-char number. + assert_eq!( + find_used_percents("a 7.5% used b 100% used c 12.25% used"), + vec![7.5, 100.0, 12.25] + ); + // Clamp out-of-range values to a sane percentage. + assert_eq!(find_used_percents("250.5% used"), vec![100.0]); + } + + #[test] + fn decimal_percent_flows_through_parse() { + let html = r#" + + +
Standard$20 / mo
+
5-hour7.5% used
+
Weekly19% used
+ +"#; + let parsed = parse_billing(html).expect("should parse"); + assert_eq!(parsed.windows[0].used_percent, 7.5); + assert_eq!(parsed.windows[1].used_percent, 19.0); + } + + // An error / shell page that happens to carry the weak substrings + // ("Billing", "/mo", "% used") inside script noise but has NO real window + // label and NO concrete $NN/mo price. This previously yielded an empty, + // all-zero card; it must now be treated as needs-auth. + const WEAK_MARKERS_ERROR_PAGE: &str = r#" + +

Something went wrong

+ + +"#; + + #[test] + fn weak_marker_error_page_returns_needs_auth() { + let err = parse_billing(WEAK_MARKERS_ERROR_PAGE).expect_err("must error, not empty card"); + assert!( + err.to_string().contains("NEEDS_AUTH"), + "expected NEEDS_AUTH, got: {err}" + ); + } + + // An expired-cookie / error shell that still carries `Billing` and a spaced + // plan price (`$20 / mo`) — so `find_monthly_price` succeeds and + // `looks_logged_out` is satisfied — but contains NO quota windows. This must + // be treated as needs-auth: a price-only page is NOT a usable billing page, + // and we must ask the user to refresh the cookie rather than emit a Sakana + // result with a plan and zero metrics. Regression for review feedback. + const PRICE_ONLY_NO_WINDOWS: &str = r#" + +

Session expired

+ +
Standard$20 / mo
+ +"#; + + #[test] + fn price_only_no_windows_returns_needs_auth() { + // Sanity: the price marker really does satisfy `looks_logged_out`, so the + // downstream guard is the thing under test. + assert!( + !looks_logged_out(PRICE_ONLY_NO_WINDOWS), + "price marker should pass the cheap logged-out heuristic" + ); + let err = + parse_billing(PRICE_ONLY_NO_WINDOWS).expect_err("price-only shell must be needs-auth"); + assert!( + err.to_string().contains("NEEDS_AUTH"), + "expected NEEDS_AUTH, got: {err}" + ); + } + + // Verifies fix (2): the cookie file is resolved via the canonical config + // dir, which honors `TOKSCALE_CONFIG_DIR`, instead of hardcoding + // `~/.config/tokscale`. Serial because it mutates process-global env. + #[test] + #[serial_test::serial] + fn session_cookie_reads_from_overridden_config_dir() { + use std::env; + + let prev_dir = env::var_os("TOKSCALE_CONFIG_DIR"); + let prev_cookie = env::var_os("SAKANA_SESSION_COOKIE"); + + let tmp = env::temp_dir().join(format!("tokscale-sakana-test-{}", std::process::id())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(tmp.join("sakana-session"), " cookie-from-file \n").unwrap(); + + unsafe { + env::set_var("TOKSCALE_CONFIG_DIR", &tmp); + // Ensure the env-var source does not short-circuit the file read. + env::remove_var("SAKANA_SESSION_COOKIE"); + } + + let got = session_cookie(); + + unsafe { + match prev_dir { + Some(v) => env::set_var("TOKSCALE_CONFIG_DIR", v), + None => env::remove_var("TOKSCALE_CONFIG_DIR"), + } + match prev_cookie { + Some(v) => env::set_var("SAKANA_SESSION_COOKIE", v), + None => env::remove_var("SAKANA_SESSION_COOKIE"), + } + } + let _ = std::fs::remove_dir_all(&tmp); + + assert_eq!(got.as_deref(), Some("cookie-from-file")); + } + + #[test] + fn monthly_price_tolerates_spacing_variants() { + assert_eq!(find_monthly_price("$20/mo").map(|(p, _)| p), Some(20)); + assert_eq!(find_monthly_price("$20 / mo").map(|(p, _)| p), Some(20)); + assert_eq!(find_monthly_price("$200 / mo").map(|(p, _)| p), Some(200)); + assert_eq!(find_monthly_price("no price here"), None); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/warp.rs b/crates/tokscale-cli/src/commands/usage/warp.rs index e4ae5d258..a3fd8af28 100644 --- a/crates/tokscale-cli/src/commands/usage/warp.rs +++ b/crates/tokscale-cli/src/commands/usage/warp.rs @@ -5,14 +5,12 @@ pub fn has_credentials() -> bool { crate::warp::load_usage_cache().is_some() } -pub fn fetch() -> Result { - let cache = crate::warp::load_usage_cache() - .ok_or_else(|| anyhow::anyhow!("Warp aggregate usage cache not found"))?; +fn build_metrics(usage: &crate::warp::WarpAggregateUsage) -> Vec { let mut metrics = Vec::new(); - if let Some(used) = cache.usage.requests_used { + if let Some(used) = usage.requests_used { let (used_percent, remaining_percent, remaining_label) = - if let Some(limit) = cache.usage.request_limit.filter(|limit| *limit > 0) { + if let Some(limit) = usage.request_limit.filter(|limit| *limit > 0) { let used_percent = (used as f64 / limit as f64 * 100.0).clamp(0.0, 100.0); let remaining = limit.saturating_sub(used); ( @@ -21,31 +19,102 @@ pub fn fetch() -> Result { Some(format!("{remaining} requests left")), ) } else { - (0.0, 0.0, Some(format!("{used} requests used"))) + // No request limit: this is an informational counter, not a + // capped quota. Render the bar as full (100% remaining) rather + // than an empty "exhausted" bar. + (0.0, 100.0, Some(format!("{used} requests used"))) }; metrics.push(UsageMetric { label: "Requests".to_string(), used_percent, remaining_percent, remaining_label, - resets_at: cache.usage.next_refresh_time.clone(), + resets_at: usage.next_refresh_time.clone(), }); } - if let Some(spend_cents) = cache.usage.spend_cents { + if let Some(spend_cents) = usage.spend_cents { metrics.push(UsageMetric { label: "Spend".to_string(), used_percent: 0.0, - remaining_percent: 0.0, + // Spend is an informational dollar figure, not a consumed quota, so + // keep the bar full instead of rendering a false "exhausted" bar. + remaining_percent: 100.0, remaining_label: Some(format!("${:.2}", spend_cents as f64 / 100.0)), - resets_at: cache.usage.next_refresh_time.clone(), + resets_at: usage.next_refresh_time.clone(), }); } + metrics +} + +pub fn fetch() -> Result { + let cache = crate::warp::load_usage_cache() + .ok_or_else(|| anyhow::anyhow!("Warp aggregate usage cache not found"))?; + let metrics = build_metrics(&cache.usage); + Ok(UsageOutput { provider: "Warp/Oz".to_string(), - plan: Some("Aggregate API cache".to_string()), + account: None, + plan: None, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::warp::WarpAggregateUsage; + + #[test] + fn spend_metric_reads_full_not_exhausted() { + let usage = WarpAggregateUsage { + spend_cents: Some(1234), + ..Default::default() + }; + let metrics = build_metrics(&usage); + let spend = metrics.iter().find(|m| m.label == "Spend").unwrap(); + // Informational $ figure: bar must read full, not a false 0%-remaining. + assert_eq!(spend.remaining_percent, 100.0); + assert_eq!(spend.used_percent, 0.0); + assert_eq!(spend.remaining_label.as_deref(), Some("$12.34")); + } + + #[test] + fn unlimited_requests_read_full_not_exhausted() { + let usage = WarpAggregateUsage { + requests_used: Some(42), + request_limit: None, + ..Default::default() + }; + let metrics = build_metrics(&usage); + let requests = metrics.iter().find(|m| m.label == "Requests").unwrap(); + assert_eq!(requests.remaining_percent, 100.0); + assert_eq!(requests.used_percent, 0.0); + assert_eq!( + requests.remaining_label.as_deref(), + Some("42 requests used") + ); + } + + #[test] + fn limited_requests_compute_usage() { + let usage = WarpAggregateUsage { + requests_used: Some(25), + request_limit: Some(100), + ..Default::default() + }; + let metrics = build_metrics(&usage); + let requests = metrics.iter().find(|m| m.label == "Requests").unwrap(); + assert_eq!(requests.used_percent, 25.0); + assert_eq!(requests.remaining_percent, 75.0); + assert_eq!( + requests.remaining_label.as_deref(), + Some("75 requests left") + ); + } +} diff --git a/crates/tokscale-cli/src/commands/usage/zai.rs b/crates/tokscale-cli/src/commands/usage/zai.rs index 146e0eff3..c957a577e 100644 --- a/crates/tokscale-cli/src/commands/usage/zai.rs +++ b/crates/tokscale-cli/src/commands/usage/zai.rs @@ -104,7 +104,12 @@ pub fn fetch() -> Result { if let Some(limits) = quota.data.as_ref().and_then(|d| d.limits.as_ref()) { for limit in limits.iter() { - let pct = limit.percentage.unwrap_or(0.0).clamp(0.0, 100.0); + // Skip limits with no percentage rather than fabricating + // "0% used / 100% left" from a missing field. + let pct = match limit.percentage { + Some(p) => p.clamp(0.0, 100.0), + None => continue, + }; match limit.limit_type.as_deref() { Some("TOKENS_LIMIT") => { @@ -163,9 +168,13 @@ pub fn fetch() -> Result { Ok(UsageOutput { provider: "Z.ai".into(), + account: None, plan, email: None, metrics, + reset_credits: None, + credit_status: None, + spend_control: None, }) }) } diff --git a/crates/tokscale-cli/src/commands/wrapped.rs b/crates/tokscale-cli/src/commands/wrapped.rs index 0ee92692f..1cafc2d10 100644 --- a/crates/tokscale-cli/src/commands/wrapped.rs +++ b/crates/tokscale-cli/src/commands/wrapped.rs @@ -271,10 +271,14 @@ async fn load_wrapped_data(options: &WrappedOptions) -> Result { tokens: 0, }); model_entry.cost += client_contrib.cost; - model_entry.tokens += client_contrib.tokens.input - + client_contrib.tokens.output - + client_contrib.tokens.cache_read - + client_contrib.tokens.cache_write; + model_entry.tokens = model_entry + .tokens + .saturating_add(crate::saturating_token_total( + client_contrib.tokens.input, + client_contrib.tokens.output, + client_contrib.tokens.cache_read, + client_contrib.tokens.cache_write, + )); let client_name = client_display_name(&client_contrib.client) .unwrap_or(client_contrib.client.as_str()) @@ -288,10 +292,15 @@ async fn load_wrapped_data(options: &WrappedOptions) -> Result { tokens: 0, }); client_entry.cost += client_contrib.cost; - client_entry.tokens += client_contrib.tokens.input - + client_contrib.tokens.output - + client_contrib.tokens.cache_read - + client_contrib.tokens.cache_write; + client_entry.tokens = + client_entry + .tokens + .saturating_add(crate::saturating_token_total( + client_contrib.tokens.input, + client_contrib.tokens.output, + client_contrib.tokens.cache_read, + client_contrib.tokens.cache_write, + )); } } @@ -366,11 +375,14 @@ fn build_top_agents(parsed: &tokscale_core::ParsedMessages) -> Vec Vec Option<&'static str> { "crush" => Some("Crush"), "goose" => Some("Goose"), "antigravity" => Some("Antigravity"), + "antigravity-cli" => Some("Antigravity CLI"), "zed" => Some("Zed Agent"), "warp" => Some("Warp"), "cline" => Some("Cline"), "gjc" => Some("Gajae-Code"), + "jcode" => Some("Jcode"), + "junie" => Some("Junie"), "synthetic" => Some("Synthetic"), _ => None, } @@ -1490,12 +1505,14 @@ fn client_logo_url(client_name: &str) -> Option<&'static str> { "Goose" => Some( "https://raw.githubusercontent.com/junhoyeo/tokscale/main/.github/assets/client-goose.png", ), - "Antigravity" => Some( + "Antigravity" | "Antigravity CLI" => Some( "https://raw.githubusercontent.com/junhoyeo/tokscale/main/.github/assets/client-antigravity.png", ), "Zed Agent" => Some( "https://raw.githubusercontent.com/junhoyeo/tokscale/main/.github/assets/client-zed.webp", ), + "Jcode" => Some("https://raw.githubusercontent.com/junhoyeo/tokscale/main/.github/assets/client-jcode.png"), + "Junie" => Some("https://github.com/JetBrains.png"), "Synthetic" => Some("https://tokscale.ai/assets/logos/synthetic.png"), _ => None, } @@ -2447,6 +2464,16 @@ mod tests { assert_eq!(client_display_name("zed"), Some("Zed Agent")); } + #[test] + fn test_client_display_name_jcode() { + assert_eq!(client_display_name("jcode"), Some("Jcode")); + } + + #[test] + fn test_client_display_name_junie() { + assert_eq!(client_display_name("junie"), Some("Junie")); + } + #[test] fn test_client_display_name_unknown() { assert_eq!(client_display_name("unknown"), None); @@ -2622,6 +2649,22 @@ mod tests { ); } + #[test] + fn test_client_logo_url_jcode() { + assert_eq!( + client_logo_url("Jcode"), + Some("https://raw.githubusercontent.com/junhoyeo/tokscale/main/.github/assets/client-jcode.png") + ); + } + + #[test] + fn test_client_logo_url_junie() { + assert_eq!( + client_logo_url("Junie"), + Some("https://github.com/JetBrains.png") + ); + } + #[test] fn test_client_logo_url_unknown() { assert_eq!(client_logo_url("Unknown"), None); diff --git a/crates/tokscale-cli/src/main.rs b/crates/tokscale-cli/src/main.rs index 8302aa7f5..477e5fc51 100644 --- a/crates/tokscale-cli/src/main.rs +++ b/crates/tokscale-cli/src/main.rs @@ -27,8 +27,8 @@ struct Cli { #[command(subcommand)] command: Option, - #[arg(short, long, default_value = "blue")] - theme: String, + #[arg(short, long)] + theme: Option, #[arg(short, long, default_value = "0")] refresh: u64, @@ -61,6 +61,12 @@ struct Cli { )] no_write_cache: bool, + #[arg( + long = "hide-zero", + help = "Hide entries whose token counts, cost, and duration are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI." + )] + hide_zero: bool, + #[command(flatten)] clients: ClientFlags, @@ -125,6 +131,11 @@ enum Commands { help = "Skip cache write even if settings.json `light.writeCache` is true. Only valid with --light." )] no_write_cache: bool, + #[arg( + long = "hide-zero", + help = "Hide entries whose token counts, cost, and duration are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI." + )] + hide_zero: bool, #[arg(long, help = "Disable spinner")] no_spinner: bool, }, @@ -140,6 +151,11 @@ enum Commands { date: DateRangeFlags, #[arg(long, help = "Show processing time")] benchmark: bool, + #[arg( + long = "hide-zero", + help = "Hide entries whose token counts and cost are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI." + )] + hide_zero: bool, #[arg(long, help = "Disable spinner")] no_spinner: bool, }, @@ -155,6 +171,11 @@ enum Commands { date: DateRangeFlags, #[arg(long, help = "Show processing time")] benchmark: bool, + #[arg( + long = "hide-zero", + help = "Hide entries whose token counts and cost are all zero. Report totals still include them. Implies the static report view instead of the interactive TUI." + )] + hide_zero: bool, #[arg(long, help = "Disable spinner")] no_spinner: bool, }, @@ -226,6 +247,11 @@ enum Commands { )] dry_run: bool, }, + #[command(about = "Manage periodic usage submission")] + Autosubmit { + #[command(subcommand)] + subcommand: commands::autosubmit::AutosubmitSubcommand, + }, #[command(about = "Capture subprocess output for token usage tracking")] Headless { #[arg(help = "Source CLI (currently only 'codex' supported)")] @@ -271,6 +297,11 @@ enum Commands { #[arg(long, help = "Light terminal output (no TUI)")] light: bool, }, + #[command(about = "Codex account integration commands")] + Codex { + #[command(subcommand)] + subcommand: CodexSubcommand, + }, #[command(about = "Cursor API cache integration commands")] Cursor { #[command(subcommand)] @@ -308,6 +339,29 @@ enum Commands { }, #[command(about = "Warm TUI cache in background (internal)", hide = true)] WarmTuiCache, + #[command(about = "Task-attributed usage report")] + Report { + #[arg(long, help = "Output as JSON")] + json: bool, + #[arg(long, help = "Filter by workspace path")] + workspace: Option, + #[arg(long, help = "Filter by client (opencode, claude, codex, etc.)")] + client: Option, + #[command(flatten)] + date: DateRangeFlags, + #[arg(long, help = "Skip LLM summarization (show raw data only)")] + no_summarize: bool, + #[arg( + long, + default_value = "apple-fm", + help = "Summarizer backend: apple-fm, claude, codex, gemini, kiro" + )] + summarizer: String, + #[arg(long, help = "Reset all summaries and re-summarize from scratch")] + rebuild: bool, + #[arg(long, help = "Show all sessions without truncation")] + full: bool, + }, } #[derive(Subcommand)] @@ -348,6 +402,42 @@ enum CursorSubcommand { }, } +#[derive(Subcommand)] +enum CodexSubcommand { + #[command(about = "Import the current Codex OAuth credentials as a saved account")] + Import { + #[arg(long, help = "Label for this Codex account (e.g., work, personal)")] + name: Option, + }, + #[command(about = "List saved Codex accounts")] + Accounts { + #[arg(long, help = "Output as JSON")] + json: bool, + }, + #[command(about = "Switch active Codex account and write Codex auth.json")] + Switch { + #[arg(help = "Account label or id")] + name: String, + }, + #[command(about = "Remove a saved Codex account")] + Remove { + #[arg(help = "Account label or id")] + name: String, + }, + #[command(about = "Check Codex subscription usage for an account")] + Status { + #[arg(long, help = "Account label or id")] + name: Option, + #[arg(long, help = "Output as JSON")] + json: bool, + }, + #[command(about = "Show an opt-in Codex account-activity snapshot")] + Activity { + #[arg(long, help = "Output as JSON")] + json: bool, + }, +} + #[derive(Subcommand)] enum AntigravitySubcommand { #[command(about = "Sync usage from running Antigravity language servers")] @@ -422,6 +512,11 @@ fn main() -> Result<()> { use std::io::IsTerminal; let cli = Cli::parse(); + // Install user-configured model aliases once, before any report/graph/TUI + // path runs, so model-name variants fold consistently across every command. + // Honors the global `--home` override exactly like scanner settings; an + // empty or absent config is a strict no-op. + tokscale_core::model_alias::set_global(&tui::settings::load_model_aliases_for_home(&cli.home)); let can_use_tui = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); if cli.test_data { @@ -438,6 +533,7 @@ fn main() -> Result<()> { group_by, write_cache, no_write_cache, + hide_zero, no_spinner, }) => { use tokscale_core::GroupBy; @@ -446,34 +542,27 @@ fn main() -> Result<()> { eprintln!("Error: {}", e); std::process::exit(1); }); - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); let clients = build_client_filter(clients, &cli.home); - if json || light || !can_use_tui { + if json || light || hide_zero || !can_use_tui { run_models_report( json, cli.home.clone(), clients, - since, - until, - year, + &date, benchmark, no_spinner || !can_use_tui, - today, - week, - month, group_by, write_cache, no_write_cache, + hide_zero, ) } else { + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); ensure_home_supported_for_tui(&cli.home)?; auto_sync_cursor_before_tui(&cli.home, &clients)?; tui::run( - &cli.theme, + cli.theme.as_deref().unwrap_or(""), cli.refresh, cli.debug, clients, @@ -490,40 +579,34 @@ fn main() -> Result<()> { clients, date, benchmark, + hide_zero, no_spinner, }) => { - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); let clients = build_client_filter(clients, &cli.home); - if json || light || !can_use_tui { + if json || light || hide_zero || !can_use_tui { run_monthly_report( json, cli.home.clone(), clients, - since, - until, - year, + &date, benchmark, no_spinner || !can_use_tui, - today, - week, - month, + hide_zero, ) } else { + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); ensure_home_supported_for_tui(&cli.home)?; auto_sync_cursor_before_tui(&cli.home, &clients)?; tui::run( - &cli.theme, + cli.theme.as_deref().unwrap_or(""), cli.refresh, cli.debug, clients, since, until, year, - Some(Tab::Daily), + Some(Tab::Monthly), ) } } @@ -533,33 +616,27 @@ fn main() -> Result<()> { clients, date, benchmark, + hide_zero, no_spinner, }) => { - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); let clients = build_client_filter(clients, &cli.home); - if json || light || !can_use_tui { + if json || light || hide_zero || !can_use_tui { run_hourly_report( json, cli.home.clone(), clients, - since, - until, - year, + &date, benchmark, no_spinner || !can_use_tui, - today, - week, - month, + hide_zero, ) } else { + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); ensure_home_supported_for_tui(&cli.home)?; auto_sync_cursor_before_tui(&cli.home, &clients)?; tui::run( - &cli.theme, + cli.theme.as_deref().unwrap_or(""), cli.refresh, cli.debug, clients, @@ -603,11 +680,8 @@ fn main() -> Result<()> { benchmark, no_spinner, }) => { - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); let clients = build_client_filter(clients, &cli.home); run_graph_command( output, @@ -622,15 +696,12 @@ fn main() -> Result<()> { } Some(Commands::Tui { clients, date }) => { ensure_home_supported_for_tui(&cli.home)?; - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); let clients = build_client_filter(clients, &cli.home); auto_sync_cursor_before_tui(&cli.home, &clients)?; tui::run( - &cli.theme, + cli.theme.as_deref().unwrap_or(""), cli.refresh, cli.debug, clients, @@ -646,18 +717,26 @@ fn main() -> Result<()> { dry_run, }) => { reject_unsupported_home_override(&cli.home, "submit")?; - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); // Bypass settings.json defaultClients for the submit path: we want the // submit-specific default_submit_clients() fallback (in run_submit_command) // to fire when the user passes no client flags, not the user's general // defaultClients view filter (which may exclude clients they still want // to upload). Pass an explicit empty defaults slice. let clients = build_client_filter_with_defaults(clients, &[]); - run_submit_command(clients, since, until, year, dry_run) + run_submit_command( + clients, + since, + until, + year, + dry_run, + SubmitMode::Interactive, + ) + } + Some(Commands::Autosubmit { subcommand }) => { + reject_unsupported_home_override(&cli.home, "autosubmit")?; + run_autosubmit_command(subcommand) } Some(Commands::Headless { source, @@ -703,6 +782,10 @@ fn main() -> Result<()> { reject_unsupported_home_override(&cli.home, "usage")?; commands::usage::run(json, light) } + Some(Commands::Codex { subcommand }) => { + reject_unsupported_home_override(&cli.home, "codex")?; + run_codex_command(subcommand) + } Some(Commands::Trae { subcommand }) => { reject_unsupported_home_override(&cli.home, "trae")?; run_trae_command(subcommand) @@ -721,11 +804,8 @@ fn main() -> Result<()> { date, no_spinner, }) => { - let today = date.today; - let week = date.week; - let month = date.month; - let (since, until) = build_date_filter(today, week, month, date.since, date.until); - let year = normalize_year_filter(today, week, month, date.year); + let (since, until) = build_date_filter(&date); + let year = normalize_year_filter(&date); let clients = build_client_filter(clients, &cli.home); run_time_metrics_report( json, @@ -738,14 +818,39 @@ fn main() -> Result<()> { ) } Some(Commands::WarmTuiCache) => run_warm_tui_cache(), + Some(Commands::Report { + json, + workspace, + client, + date, + no_summarize, + summarizer, + rebuild, + full, + }) => { + let today = date.today; + let week = date.week; + let month = date.month; + let (since, until) = build_date_filter(&date); + commands::report::run_report(commands::report::ReportOptions { + json, + since, + until, + workspace, + client, + no_summarize, + summarizer, + rebuild, + home_dir: cli.home.clone(), + scanner_settings: tui::settings::load_scanner_settings(), + today, + week, + month, + full, + }) + } None => { - let today = cli.date.today; - let week = cli.date.week; - let month = cli.date.month; let clients = build_client_filter(cli.clients, &cli.home); - let (since, until) = - build_date_filter(today, week, month, cli.date.since, cli.date.until); - let year = normalize_year_filter(today, week, month, cli.date.year); let group_by: tokscale_core::GroupBy = cli.group_by.parse().unwrap_or_else(|e| { eprintln!("Error: {}", e); std::process::exit(1); @@ -756,40 +861,34 @@ fn main() -> Result<()> { cli.json, cli.home.clone(), clients, - since, - until, - year, + &cli.date, cli.benchmark, cli.no_spinner || cli.json, - today, - week, - month, group_by, cli.write_cache, cli.no_write_cache, + cli.hide_zero, ) - } else if cli.light || !can_use_tui { + } else if cli.light || cli.hide_zero || !can_use_tui { run_models_report( false, cli.home.clone(), clients, - since, - until, - year, + &cli.date, cli.benchmark, cli.no_spinner || !can_use_tui, - today, - week, - month, group_by, cli.write_cache, cli.no_write_cache, + cli.hide_zero, ) } else { + let (since, until) = build_date_filter(&cli.date); + let year = normalize_year_filter(&cli.date); ensure_home_supported_for_tui(&cli.home)?; auto_sync_cursor_before_tui(&cli.home, &clients)?; tui::run( - &cli.theme, + cli.theme.as_deref().unwrap_or(""), cli.refresh, cli.debug, clients, @@ -847,6 +946,21 @@ pub enum ClientFilter { Warp, Cline, Gjc, + Grok, + Jcode, + Commandcode, + Micode, + #[value(name = "antigravity-cli")] + AntigravityCli, + Junie, + Zcode, + Opencodereview, + Codebuddy, + Workbuddy, + #[value(name = "devin-cli")] + DevinCli, + #[value(name = "devin-desktop")] + DevinDesktop, Synthetic, } @@ -883,6 +997,18 @@ impl ClientFilter { Self::Warp => "warp", Self::Cline => "cline", Self::Gjc => "gjc", + Self::Grok => "grok", + Self::Jcode => "jcode", + Self::Commandcode => "commandcode", + Self::Micode => "micode", + Self::AntigravityCli => "antigravity-cli", + Self::Junie => "junie", + Self::Zcode => "zcode", + Self::Opencodereview => "opencodereview", + Self::Codebuddy => "codebuddy", + Self::Workbuddy => "workbuddy", + Self::DevinCli => "devin-cli", + Self::DevinDesktop => "devin-desktop", Self::Synthetic => "synthetic", } } @@ -922,6 +1048,18 @@ impl ClientFilter { Self::Warp => Some(ClientId::Warp), Self::Cline => Some(ClientId::Cline), Self::Gjc => Some(ClientId::Gjc), + Self::Grok => Some(ClientId::Grok), + Self::Jcode => Some(ClientId::Jcode), + Self::Commandcode => Some(ClientId::CommandCode), + Self::Micode => Some(ClientId::MiMoCode), + Self::AntigravityCli => Some(ClientId::AntigravityCli), + Self::Junie => Some(ClientId::Junie), + Self::Zcode => Some(ClientId::Zcode), + Self::Opencodereview => Some(ClientId::OpenCodeReview), + Self::Codebuddy => Some(ClientId::CodeBuddy), + Self::Workbuddy => Some(ClientId::WorkBuddy), + Self::DevinCli => Some(ClientId::DevinCli), + Self::DevinDesktop => Some(ClientId::DevinDesktop), Self::Synthetic => None, } } @@ -958,6 +1096,18 @@ impl ClientFilter { ClientId::Warp => Self::Warp, ClientId::Cline => Self::Cline, ClientId::Gjc => Self::Gjc, + ClientId::Grok => Self::Grok, + ClientId::Jcode => Self::Jcode, + ClientId::CommandCode => Self::Commandcode, + ClientId::MiMoCode => Self::Micode, + ClientId::AntigravityCli => Self::AntigravityCli, + ClientId::Junie => Self::Junie, + ClientId::Zcode => Self::Zcode, + ClientId::OpenCodeReview => Self::Opencodereview, + ClientId::CodeBuddy => Self::Codebuddy, + ClientId::WorkBuddy => Self::Workbuddy, + ClientId::DevinCli => Self::DevinCli, + ClientId::DevinDesktop => Self::DevinDesktop, } } @@ -997,8 +1147,10 @@ pub struct ClientFlags { /// Canonical client filter. Repeatable or comma-separated. /// Example: `--client opencode,claude` or `-c opencode -c claude`. #[arg( + id = "client_filter", long = "client", short = 'c', + value_name = "CLIENTS", value_enum, value_delimiter = ',', action = clap::ArgAction::Append, @@ -1006,75 +1158,33 @@ pub struct ClientFlags { help = "Filter by client(s). Repeatable or comma-separated (e.g. -c opencode,claude)." )] pub clients: Vec, - - // ---- Deprecated legacy boolean flags ------------------------------ - // Hidden from --help. Kept for backward compatibility; print a stderr - // deprecation warning when used. Slated for removal in the next major. - #[arg(long, hide = true)] - pub opencode: bool, - #[arg(long, hide = true)] - pub claude: bool, - #[arg(long, hide = true)] - pub codex: bool, - #[arg(long, hide = true)] - pub copilot: bool, - #[arg(long, hide = true)] - pub gemini: bool, - #[arg(long, hide = true)] - pub cursor: bool, - #[arg(long, hide = true)] - pub amp: bool, - #[arg(long, hide = true)] - pub codebuff: bool, - #[arg(long, hide = true)] - pub droid: bool, - #[arg(long, hide = true)] - pub openclaw: bool, - #[arg(long, hide = true)] - pub hermes: bool, - #[arg(long, hide = true)] - pub pi: bool, - #[arg(long, hide = true)] - pub kimi: bool, - #[arg(long, hide = true)] - pub qwen: bool, - #[arg(long, hide = true)] - pub roocode: bool, - #[arg(long, hide = true)] - pub kilocode: bool, - #[arg(long, hide = true)] - pub kilo: bool, - #[arg(long, hide = true)] - pub mux: bool, - #[arg(long, hide = true)] - pub crush: bool, - #[arg(long, hide = true)] - pub goose: bool, - #[arg(long, hide = true)] - pub antigravity: bool, - #[arg(long, hide = true)] - pub zed: bool, - #[arg(long, hide = true)] - pub kiro: bool, - #[arg(long, hide = true)] - pub trae: bool, - #[arg(long, hide = true)] - pub warp: bool, - #[arg(long, hide = true)] - pub cline: bool, - #[arg(long, hide = true)] - pub gjc: bool, - #[arg(long, hide = true)] - pub synthetic: bool, } #[derive(Args, Clone, Debug, Default)] pub struct DateRangeFlags { - #[arg(long, help = "Show only today's usage")] + #[arg( + long, + help = "Show only today's usage", + conflicts_with_all = ["yesterday", "week", "month", "since", "until", "year"] + )] pub today: bool, - #[arg(long, help = "Show last 7 days")] + #[arg( + long, + help = "Show only yesterday's usage", + conflicts_with_all = ["week", "month", "since", "until", "year"] + )] + pub yesterday: bool, + #[arg( + long, + help = "Show last 7 days", + conflicts_with_all = ["month", "since", "until", "year"] + )] pub week: bool, - #[arg(long, help = "Show current month")] + #[arg( + long, + help = "Show current month", + conflicts_with_all = ["since", "until", "year"] + )] pub month: bool, #[arg(long, help = "Start date (YYYY-MM-DD)")] pub since: Option, @@ -1088,11 +1198,9 @@ pub struct DateRangeFlags { /// /// Resolution order: /// 1. Collect canonical `--client/-c` values (preserves user order). -/// 2. Append any legacy `--` boolean flags that are set, emitting a -/// one-time stderr deprecation warning so existing scripts keep working. -/// 3. If steps 1 and 2 produced nothing, fall back to user-configured +/// 2. If step 1 produced nothing, fall back to user-configured /// `defaultClients` from `~/.config/tokscale/settings.json` when present. -/// 4. Deduplicate while preserving first-seen order. +/// 3. Deduplicate while preserving first-seen order. /// /// Returns `None` when no filters are active *and* no defaults configured /// so the caller can scan all clients. @@ -1118,57 +1226,10 @@ fn build_client_filter_with_defaults( } } - let legacy: [(bool, ClientFilter); 28] = [ - (flags.opencode, ClientFilter::Opencode), - (flags.claude, ClientFilter::Claude), - (flags.codex, ClientFilter::Codex), - (flags.cursor, ClientFilter::Cursor), - (flags.gemini, ClientFilter::Gemini), - (flags.amp, ClientFilter::Amp), - (flags.codebuff, ClientFilter::Codebuff), - (flags.droid, ClientFilter::Droid), - (flags.openclaw, ClientFilter::Openclaw), - (flags.pi, ClientFilter::Pi), - (flags.kimi, ClientFilter::Kimi), - (flags.qwen, ClientFilter::Qwen), - (flags.roocode, ClientFilter::Roocode), - (flags.kilocode, ClientFilter::Kilocode), - (flags.mux, ClientFilter::Mux), - (flags.kilo, ClientFilter::Kilo), - (flags.crush, ClientFilter::Crush), - (flags.hermes, ClientFilter::Hermes), - (flags.copilot, ClientFilter::Copilot), - (flags.goose, ClientFilter::Goose), - (flags.antigravity, ClientFilter::Antigravity), - (flags.zed, ClientFilter::Zed), - (flags.kiro, ClientFilter::Kiro), - (flags.trae, ClientFilter::Trae), - (flags.warp, ClientFilter::Warp), - (flags.cline, ClientFilter::Cline), - (flags.gjc, ClientFilter::Gjc), - (flags.synthetic, ClientFilter::Synthetic), - ]; - - let mut legacy_used: Vec<&'static str> = Vec::new(); - for (enabled, client) in legacy { - if !enabled { - continue; - } - let id = client.as_filter_str(); - legacy_used.push(id); - if seen.insert(id.to_string()) { - ordered.push(id.to_string()); - } - } - - if !legacy_used.is_empty() { - emit_legacy_client_flag_warning(&legacy_used); - } - - // Defaults only apply when the user passed neither canonical nor legacy - // flags. CLI flags always win — predictable semantics over "merge". - // Unknown / typo'd ids are dropped silently so a stale settings.json - // entry never breaks tokscale. + // Defaults only apply when the user passed no canonical `--client` flags. + // CLI flags always win — predictable semantics over "merge". Unknown / + // typo'd ids are dropped silently so a stale settings.json entry never + // breaks tokscale. if ordered.is_empty() { for raw in defaults { if let Some(client) = ClientFilter::from_filter_str(raw) { @@ -1187,22 +1248,6 @@ fn build_client_filter_with_defaults( } } -/// Emits a single stderr deprecation warning when legacy `--` flags -/// are used. Suppressed entirely when stderr is not a TTY (e.g. when piping -/// JSON output through scripts) so machine-parseable output stays clean. -fn emit_legacy_client_flag_warning(used: &[&'static str]) { - if !std::io::stderr().is_terminal() { - return; - } - let pretty: Vec = used.iter().map(|id| format!("--{id}")).collect(); - let replacement = used.join(","); - eprintln!( - "warning: {} is deprecated; use `--client {}` instead. The legacy flags will be removed in the next major release.", - pretty.join(", "), - replacement - ); -} - fn client_filter_includes_cursor(clients: &Option>) -> bool { clients .as_ref() @@ -1514,39 +1559,29 @@ fn ensure_home_supported_for_tui(home_dir: &Option) -> Result<()> { Ok(()) } -fn build_date_filter( - today: bool, - week: bool, - month: bool, - since: Option, - until: Option, -) -> (Option, Option) { - build_date_filter_for_date( - today, - week, - month, - since, - until, - chrono::Local::now().date_naive(), - ) +fn build_date_filter(date: &DateRangeFlags) -> (Option, Option) { + build_date_filter_for_date(date, chrono::Local::now().date_naive()) } fn build_date_filter_for_date( - today: bool, - week: bool, - month: bool, - since: Option, - until: Option, + date: &DateRangeFlags, current_date: chrono::NaiveDate, ) -> (Option, Option) { use chrono::{Datelike, Duration}; - if today { - let date = current_date.format("%Y-%m-%d").to_string(); - return (Some(date.clone()), Some(date)); + if date.today { + let day = current_date.format("%Y-%m-%d").to_string(); + return (Some(day.clone()), Some(day)); + } + + if date.yesterday { + let day = (current_date - Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + return (Some(day.clone()), Some(day)); } - if week { + if date.week { let start = current_date - Duration::days(6); return ( Some(start.format("%Y-%m-%d").to_string()), @@ -1554,7 +1589,7 @@ fn build_date_filter_for_date( ); } - if month { + if date.month { let start = current_date.with_day(1).unwrap_or(current_date); return ( Some(start.format("%Y-%m-%d").to_string()), @@ -1562,67 +1597,45 @@ fn build_date_filter_for_date( ); } - (since, until) + (date.since.clone(), date.until.clone()) } -fn normalize_year_filter( - today: bool, - week: bool, - month: bool, - year: Option, -) -> Option { - if today || week || month { +fn normalize_year_filter(date: &DateRangeFlags) -> Option { + if date.today || date.yesterday || date.week || date.month { None } else { - year + date.year.clone() } } -fn get_date_range_label( - today: bool, - week: bool, - month: bool, - since: &Option, - until: &Option, - year: &Option, -) -> Option { - get_date_range_label_for_date( - today, - week, - month, - since, - until, - year, - chrono::Local::now().date_naive(), - ) +fn get_date_range_label(date: &DateRangeFlags) -> Option { + get_date_range_label_for_date(date, chrono::Local::now().date_naive()) } fn get_date_range_label_for_date( - today: bool, - week: bool, - month: bool, - since: &Option, - until: &Option, - year: &Option, + date: &DateRangeFlags, current_date: chrono::NaiveDate, ) -> Option { - if today { + if date.today { return Some("Today".to_string()); } - if week { + if date.yesterday { + return Some("Yesterday".to_string()); + } + if date.week { return Some("Last 7 days".to_string()); } - if month { + if date.month { return Some(current_date.format("%B %Y").to_string()); } - if let Some(y) = year { + if let Some(y) = &date.year { return Some(y.clone()); } let mut parts = Vec::new(); - if let Some(s) = since { + if let Some(s) = &date.since { parts.push(format!("from {}", s)); } - if let Some(u) = until { + if let Some(u) = &date.until { parts.push(format!("to {}", u)); } if parts.is_empty() { @@ -1749,23 +1762,21 @@ fn run_models_report( json: bool, home_dir: Option, clients: Option>, - since: Option, - until: Option, - year: Option, + date: &DateRangeFlags, benchmark: bool, no_spinner: bool, - today: bool, - week: bool, - month_flag: bool, group_by: tokscale_core::GroupBy, cli_write_cache: bool, cli_no_write_cache: bool, + hide_zero: bool, ) -> Result<()> { use std::time::Instant; use tokio::runtime::Runtime; use tokscale_core::{get_model_report, GroupBy, ReportOptions}; - let date_range = get_date_range_label(today, week, month_flag, &since, &until, &year); + let (since, until) = build_date_filter(date); + let year = normalize_year_filter(date); + let date_range = get_date_range_label(date); let effective_home_dir = resolve_effective_home_dir(&home_dir); let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir); @@ -1795,6 +1806,21 @@ fn run_models_report( .await }) .map_err(|e| anyhow::anyhow!(e))?; + let mut report = report; + if hide_zero { + // Display-only filter: totals were computed in core over the full + // entry set and intentionally still include the hidden rows. + report.entries.retain(|e| { + e.input != 0 + || e.output != 0 + || e.cache_read != 0 + || e.cache_write != 0 + || e.reasoning != 0 + || e.cost != 0.0 + || e.performance.total_duration_ms != 0 + }); + } + let report = report; if let Some(spinner) = spinner { spinner.stop(); @@ -1958,11 +1984,18 @@ fn run_models_report( .map(capitalize_client) .collect::>() .join(", "); - let total_tokens = - entry.input + entry.output + entry.cache_read + entry.cache_write; + let total_tokens = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(capitalized_clients), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -1977,10 +2010,12 @@ fn run_models_report( ]); } - let total_tokens = report.total_input - + report.total_output - + report.total_cache_read - + report.total_cache_write; + let total_tokens = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); table.add_row(vec![ Cell::new("Total") .fg(Color::Yellow) @@ -2017,11 +2052,18 @@ fn run_models_report( ]); for entry in &report.entries { - let total_tokens = - entry.input + entry.output + entry.cache_read + entry.cache_write; + let total_tokens = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(capitalize_client(&entry.client)), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -2036,10 +2078,12 @@ fn run_models_report( ]); } - let total_tokens = report.total_input - + report.total_output - + report.total_cache_read - + report.total_cache_write; + let total_tokens = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); table.add_row(vec![ Cell::new("Total") .fg(Color::Yellow) @@ -2078,8 +2122,12 @@ fn run_models_report( table.set_header(header); for entry in &report.entries { - let total_tokens = - entry.input + entry.output + entry.cache_read + entry.cache_write; + let total_tokens = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); let session_label = entry .session_id .clone() @@ -2099,10 +2147,12 @@ fn run_models_report( table.add_row(row); } - let total_all = report.total_input - + report.total_output - + report.total_cache_read - + report.total_cache_write; + let total_all = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); let mut total_row = Vec::with_capacity(6); if show_client { total_row.push( @@ -2182,8 +2232,12 @@ fn run_models_report( ]); for entry in &report.entries { - let total = - entry.input + entry.output + entry.cache_write + entry.cache_read; + let total = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); let clients_str = entry.merged_clients.as_deref().unwrap_or(&entry.client); let capitalized_clients = clients_str @@ -2193,7 +2247,10 @@ fn run_models_report( .join(", "); table.add_row(vec![ Cell::new(capitalized_clients), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -2214,10 +2271,12 @@ fn run_models_report( ]); } - let total_all = report.total_input - + report.total_output - + report.total_cache_write - + report.total_cache_read; + let total_all = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); table.add_row(vec![ Cell::new("Total") .fg(Color::Yellow) @@ -2269,8 +2328,12 @@ fn run_models_report( table.set_header(header); for entry in &report.entries { - let total = - entry.input + entry.output + entry.cache_write + entry.cache_read; + let total = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); let session_label = entry .session_id .clone() @@ -2281,7 +2344,10 @@ fn run_models_report( } row.extend([ Cell::new(session_label), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) .set_alignment(CellAlignment::Right), @@ -2297,10 +2363,12 @@ fn run_models_report( table.add_row(row); } - let total_all = report.total_input - + report.total_output - + report.total_cache_write - + report.total_cache_read; + let total_all = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); let mut total_row: Vec = Vec::with_capacity(9); total_row.push( Cell::new("Total") @@ -2355,12 +2423,19 @@ fn run_models_report( ]); for entry in &report.entries { - let total = - entry.input + entry.output + entry.cache_write + entry.cache_read; + let total = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(capitalize_client(&entry.client)), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(&entry.model), Cell::new(format_model_name(&entry.model)), Cell::new(format_tokens_with_commas(entry.input)) @@ -2382,10 +2457,12 @@ fn run_models_report( ]); } - let total_all = report.total_input - + report.total_output - + report.total_cache_write - + report.total_cache_read; + let total_all = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); table.add_row(vec![ Cell::new("Total") .fg(Color::Yellow) @@ -2435,8 +2512,12 @@ fn run_models_report( ]); for entry in &report.entries { - let total = - entry.input + entry.output + entry.cache_write + entry.cache_read; + let total = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); let clients_str = entry.merged_clients.as_deref().unwrap_or(&entry.client); let capitalized_clients = clients_str .split(", ") @@ -2446,7 +2527,10 @@ fn run_models_report( table.add_row(vec![ Cell::new(workspace_name(entry.workspace_label.as_deref())), - Cell::new(&entry.provider).add_attribute(Attribute::Dim), + Cell::new(crate::tui::ui::widgets::get_provider_display_name( + &entry.provider, + )) + .add_attribute(Attribute::Dim), Cell::new(capitalized_clients), Cell::new(&entry.model), Cell::new(format_tokens_with_commas(entry.input)) @@ -2466,10 +2550,12 @@ fn run_models_report( ]); } - let total_all = report.total_input - + report.total_output - + report.total_cache_write - + report.total_cache_read; + let total_all = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); table.add_row(vec![ Cell::new("Total") .fg(Color::Yellow) @@ -2510,10 +2596,12 @@ fn run_models_report( println!("\n \x1b[36m{}\x1b[0m\n", title); println!("{}", dim_borders(&table.to_string())); - let total_tokens = report.total_input - + report.total_output - + report.total_cache_write - + report.total_cache_read; + let total_tokens = saturating_token_total( + report.total_input, + report.total_output, + report.total_cache_read, + report.total_cache_write, + ); println!( "\x1b[90m\n Total: {} messages, {} tokens, \x1b[32m{}\x1b[90m\x1b[0m", format_tokens_with_commas(report.total_messages as i64), @@ -2540,25 +2628,22 @@ fn run_models_report( Ok(()) } -#[allow(clippy::too_many_arguments)] fn run_monthly_report( json: bool, home_dir: Option, clients: Option>, - since: Option, - until: Option, - year: Option, + date: &DateRangeFlags, benchmark: bool, no_spinner: bool, - today: bool, - week: bool, - month_flag: bool, + hide_zero: bool, ) -> Result<()> { use std::time::Instant; use tokio::runtime::Runtime; use tokscale_core::{get_monthly_report, GroupBy, ReportOptions}; - let date_range = get_date_range_label(today, week, month_flag, &since, &until, &year); + let (since, until) = build_date_filter(date); + let year = normalize_year_filter(date); + let date_range = get_date_range_label(date); let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir); let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients); @@ -2587,6 +2672,18 @@ fn run_monthly_report( .await }) .map_err(|e| anyhow::anyhow!(e))?; + let mut report = report; + if hide_zero { + // Display-only filter: totals still include the hidden rows. + report.entries.retain(|e| { + e.input != 0 + || e.output != 0 + || e.cache_read != 0 + || e.cache_write != 0 + || e.cost != 0.0 + }); + } + let report = report; if let Some(spinner) = spinner { spinner.stop(); @@ -2690,8 +2787,12 @@ fn run_monthly_report( .collect::>() .join("\n") }; - let total_tokens = - entry.input + entry.output + entry.cache_read + entry.cache_write; + let total_tokens = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(entry.month.clone()), @@ -2706,11 +2807,14 @@ fn run_monthly_report( ]); } - let total_input: i64 = report.entries.iter().map(|e| e.input).sum(); - let total_output: i64 = report.entries.iter().map(|e| e.output).sum(); - let total_cache_read: i64 = report.entries.iter().map(|e| e.cache_read).sum(); - let total_cache_write: i64 = report.entries.iter().map(|e| e.cache_write).sum(); - let total_tokens = total_input + total_output + total_cache_read + total_cache_write; + let (total_input, total_output, total_cache_read, total_cache_write) = + monthly_token_field_totals(&report.entries); + let total_tokens = saturating_token_total( + total_input, + total_output, + total_cache_read, + total_cache_write, + ); table.add_row(vec![ Cell::new("Total") .fg(Color::Yellow) @@ -2760,7 +2864,12 @@ fn run_monthly_report( .collect::>() .join("\n") }; - let total = entry.input + entry.output + entry.cache_write + entry.cache_read; + let total = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(entry.month.clone()), @@ -2780,11 +2889,14 @@ fn run_monthly_report( ]); } - let total_input: i64 = report.entries.iter().map(|e| e.input).sum(); - let total_output: i64 = report.entries.iter().map(|e| e.output).sum(); - let total_cache_write: i64 = report.entries.iter().map(|e| e.cache_write).sum(); - let total_cache_read: i64 = report.entries.iter().map(|e| e.cache_read).sum(); - let total_all = total_input + total_output + total_cache_write + total_cache_read; + let (total_input, total_output, total_cache_read, total_cache_write) = + monthly_token_field_totals(&report.entries); + let total_all = saturating_token_total( + total_input, + total_output, + total_cache_read, + total_cache_write, + ); table.add_row(vec![ Cell::new("Total") @@ -2839,25 +2951,22 @@ fn run_monthly_report( Ok(()) } -#[allow(clippy::too_many_arguments)] fn run_hourly_report( json: bool, home_dir: Option, clients: Option>, - since: Option, - until: Option, - year: Option, + date: &DateRangeFlags, benchmark: bool, no_spinner: bool, - today: bool, - week: bool, - month_flag: bool, + hide_zero: bool, ) -> Result<()> { use std::time::Instant; use tokio::runtime::Runtime; use tokscale_core::{get_hourly_report, GroupBy, ReportOptions}; - let date_range = get_date_range_label(today, week, month_flag, &since, &until, &year); + let (since, until) = build_date_filter(date); + let year = normalize_year_filter(date); + let date_range = get_date_range_label(date); let had_cursor_cache = has_cursor_usage_cache_for_report(&home_dir); let explicit_cursor_filter = client_filter_explicitly_requests_cursor(&clients); @@ -2886,6 +2995,19 @@ fn run_hourly_report( .await }) .map_err(|e| anyhow::anyhow!(e))?; + let mut report = report; + if hide_zero { + // Display-only filter: totals still include the hidden rows. + report.entries.retain(|e| { + e.input != 0 + || e.output != 0 + || e.cache_read != 0 + || e.cache_write != 0 + || e.reasoning != 0 + || e.cost != 0.0 + }); + } + let report = report; if let Some(spinner) = spinner { spinner.stop(); @@ -2990,8 +3112,12 @@ fn run_hourly_report( } else { "—".to_string() }; - let total_tokens = - entry.input + entry.output + entry.cache_read + entry.cache_write; + let total_tokens = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(&entry.hour).fg(Color::White), Cell::new(&clients_col), @@ -3064,8 +3190,12 @@ fn run_hourly_report( "—".to_string() }; - let total_tokens = - entry.input + entry.output + entry.cache_read + entry.cache_write; + let total_tokens = saturating_token_total( + entry.input, + entry.output, + entry.cache_read, + entry.cache_write, + ); table.add_row(vec![ Cell::new(&entry.hour).fg(Color::White), @@ -3469,12 +3599,52 @@ fn format_ms_per_1k(ms_per_1k_tokens: Option) -> String { } } +/// Saturating sum of the four billable token buckets (input/output/cache +/// read/cache write) used throughout the display layer for per-row and +/// grand-total token counts. tokscale-core saturates these fields at the +/// per-message and per-entry level (see `TokenBreakdown::total` and +/// `model_report_token_totals`), so a corrupt/misbehaving source can +/// legitimately clamp a bucket to `i64::MAX`; combining up to four such +/// buckets with plain `+` can then overflow (debug panic / release wrap). +/// `saturating_add` keeps this fold a no-op for real token counts and only +/// changes behavior in that already-degraded case. +fn saturating_token_total(input: i64, output: i64, cache_read: i64, cache_write: i64) -> i64 { + input + .saturating_add(output) + .saturating_add(cache_read) + .saturating_add(cache_write) +} + +/// Sum the (input, output, cache_read, cache_write) token fields across +/// monthly usage entries with saturating_add. `MonthlyReport` (unlike +/// `ModelReport`) doesn't carry precomputed grand totals, so the display +/// layer aggregates `report.entries` itself; a saturating fold keeps that +/// aggregation safe against clamped (i64::MAX) entry buckets. +fn monthly_token_field_totals(entries: &[tokscale_core::MonthlyUsage]) -> (i64, i64, i64, i64) { + entries.iter().fold( + (0, 0, 0, 0), + |(input, output, cache_read, cache_write), entry| { + ( + input.saturating_add(entry.input), + output.saturating_add(entry.output), + cache_read.saturating_add(entry.cache_read), + cache_write.saturating_add(entry.cache_write), + ) + }, + ) +} + fn model_entry_total_tokens(entry: &tokscale_core::ModelUsage) -> i64 { - entry.input.max(0) - + entry.output.max(0) - + entry.cache_read.max(0) - + entry.cache_write.max(0) - + entry.reasoning.max(0) + // saturating_add (mirrors tokscale_core::TokenBreakdown::total) so a + // clamped (i64::MAX) bucket from a corrupt source can't overflow the + // per-entry sum. + entry + .input + .max(0) + .saturating_add(entry.output.max(0)) + .saturating_add(entry.cache_read.max(0)) + .saturating_add(entry.cache_write.max(0)) + .saturating_add(entry.reasoning.max(0)) } fn aggregate_model_report_performance( @@ -3492,7 +3662,13 @@ fn aggregate_model_report_performance( .sample_count .saturating_add(entry.performance.sample_count); } - let total_tokens = entries.iter().map(model_entry_total_tokens).sum(); + // saturating fold: model_entry_total_tokens already saturates per entry, + // but two saturated (i64::MAX) entries folded with plain `.sum()` can + // still overflow the cross-entry total. + let total_tokens = entries + .iter() + .map(model_entry_total_tokens) + .fold(0i64, i64::saturating_add); performance.finalize(total_tokens); performance } @@ -3561,8 +3737,17 @@ fn capitalize_client(client: &str) -> String { "hermes" => "Hermes Agent".to_string(), "goose" => "Goose".to_string(), "warp" => "Warp".to_string(), + "grok" => "Grok Build".to_string(), "pi" => "Pi".to_string(), "gjc" => "Gajae-Code".to_string(), + "jcode" => "Jcode".to_string(), + "commandcode" => "Command Code".to_string(), + "junie" => "Junie".to_string(), + "zcode" => "ZCode".to_string(), + "codebuddy" => "CodeBuddy".to_string(), + "workbuddy" => "WorkBuddy".to_string(), + "devin-cli" => "Devin CLI".to_string(), + "devin-desktop" => "Devin Desktop".to_string(), other => other.to_string(), } } @@ -3678,7 +3863,7 @@ fn run_clients_command(json: bool, home_dir: Option) -> Result<()> { .data() .resolve_path_with_env_strategy(&home_dir_str, use_env_roots); let sessions_path_exists = Path::new(&sessions_path).exists(); - let additional_paths: Vec = built_in_extra_paths + let mut additional_paths: Vec = built_in_extra_paths .iter() .filter(|(c, _)| *c == client) .map(|(_, path)| AdditionalPath { @@ -3686,6 +3871,13 @@ fn run_clients_command(json: bool, home_dir: Option) -> Result<()> { exists: path.exists(), }) .collect(); + if client == ClientId::Zcode { + let path = home_dir.join(".zcode/cli/db/db.sqlite"); + additional_paths.push(AdditionalPath { + path: path.to_string_lossy().to_string(), + exists: path.exists(), + }); + } let legacy_paths = if client == ClientId::OpenClaw { vec![ LegacyPath { @@ -3740,6 +3932,7 @@ fn run_clients_command(json: bool, home_dir: Option) -> Result<()> { ClientId::Gemini => "Gemini CLI", ClientId::Cursor => "Cursor IDE", ClientId::Kimi => "Kimi CLI", + ClientId::AntigravityCli => "Antigravity CLI", _ => client_ui::display_name(client), } .to_string(); @@ -4757,34 +4950,6 @@ struct SubmitMetrics { sources: Option>, } -fn cap_graph_result_to_utc_today( - graph_result: &mut tokscale_core::GraphResult, - utc_today: &str, -) -> bool { - let pre_cap_len = graph_result.contributions.len(); - graph_result - .contributions - .retain(|c| c.date.as_str() <= utc_today); - if graph_result.contributions.len() == pre_cap_len { - return false; - } - - graph_result.meta.date_range_start = graph_result - .contributions - .first() - .map(|c| c.date.clone()) - .unwrap_or_default(); - graph_result.meta.date_range_end = graph_result - .contributions - .last() - .map(|c| c.date.clone()) - .unwrap_or_default(); - graph_result.summary = tokscale_core::calculate_summary(&graph_result.contributions); - graph_result.years = tokscale_core::calculate_years(&graph_result.contributions); - - true -} - /// A client row dropped from a submission because it carried cost without any /// token attribution. See [`exclude_tokenless_cost_contributions`]. #[derive(Debug, Clone, PartialEq)] @@ -4797,7 +4962,9 @@ struct ExcludedTokenlessRow { } fn client_token_total(tokens: &tokscale_core::TokenBreakdown) -> i64 { - tokens.input + tokens.output + tokens.cache_read + tokens.cache_write + tokens.reasoning + // TokenBreakdown::total() already saturating_adds its fields so a clamped + // (i64::MAX) bucket from a corrupt source can't overflow this display fold. + tokens.total() } /// Cursor's pre-2025-05 exports include `premium-tool-call` rows billed per @@ -4937,12 +5104,67 @@ fn report_excluded_tokenless_rows(excluded: &[ExcludedTokenlessRow]) { println!(); } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SubmitMode { + Interactive, + Autosubmit, +} + +fn run_autosubmit_command(subcommand: commands::autosubmit::AutosubmitSubcommand) -> Result<()> { + use commands::autosubmit::{AutosubmitRunDecision, AutosubmitSubcommand}; + + match subcommand { + AutosubmitSubcommand::Enable(args) => commands::autosubmit::enable(args), + AutosubmitSubcommand::Status { json } => commands::autosubmit::status(json), + AutosubmitSubcommand::Disable => commands::autosubmit::disable(), + AutosubmitSubcommand::Run { force } => { + let now_ms = chrono::Utc::now().timestamp_millis(); + let (settings, decision) = commands::autosubmit::load_run_config(force, now_ms)?; + match decision { + AutosubmitRunDecision::Disabled => { + println!("Autosubmit is disabled."); + return Ok(()); + } + AutosubmitRunDecision::NotDue { next_run_at_ms } => { + println!( + "Autosubmit is not due yet. Next run: {}.", + commands::autosubmit::format_timestamp_ms(next_run_at_ms) + ); + return Ok(()); + } + AutosubmitRunDecision::Due => {} + } + + let Some(_lock) = commands::autosubmit::try_acquire_run_lock()? else { + println!("Autosubmit is already running."); + return Ok(()); + }; + + let (clients, since, until, year) = commands::autosubmit::submit_filters(&settings); + match run_submit_command(clients, since, until, year, false, SubmitMode::Autosubmit) { + Ok(()) => { + commands::autosubmit::record_run_success( + chrono::Utc::now().timestamp_millis(), + )?; + Ok(()) + } + Err(err) => { + let message = err.to_string(); + let _ = commands::autosubmit::record_run_error(&message); + Err(err) + } + } + } + } +} + fn run_submit_command( clients: Option>, since: Option, until: Option, year: Option, dry_run: bool, + mode: SubmitMode, ) -> Result<()> { use colored::Colorize; use std::io::IsTerminal; @@ -4952,6 +5174,11 @@ fn run_submit_command( let auth_token = match auth::resolve_api_token() { Some(token) => token, None => { + if mode == SubmitMode::Autosubmit { + return Err(anyhow::anyhow!( + "Autosubmit requires login. Run `tokscale login` or set TOKSCALE_API_TOKEN." + )); + } eprintln!("\n {}", "Not logged in.".yellow()); eprintln!( "{}", @@ -4961,7 +5188,8 @@ fn run_submit_command( } }; - if auth_token.source == auth::ApiTokenSource::StoredCredentials + if mode == SubmitMode::Interactive + && auth_token.source == auth::ApiTokenSource::StoredCredentials && std::io::stdin().is_terminal() && std::io::stdout().is_terminal() { @@ -5007,7 +5235,7 @@ fn run_submit_command( println!("{}", " Scanning local session data...".bright_black()); let rt = Runtime::new()?; - let graph_result = rt + let mut graph_result = rt .block_on(async { generate_graph(ReportOptions { home_dir: None, @@ -5023,17 +5251,9 @@ fn run_submit_command( }) .map_err(|e| anyhow::anyhow!(e))?; - // Cap contributions to UTC today to prevent timezone-related future-date - // rejections. The CLI generates dates using chrono::Local, but the server - // validates against UTC. In UTC+ timezones the local date can be ahead of - // UTC around midnight, causing valid same-day data to be flagged as - // "future dates". Capped contributions will be included in the next - // submission once the UTC date catches up. - // See: https://github.com/junhoyeo/tokscale/issues/318 - let utc_today = chrono::Utc::now().format("%Y-%m-%d").to_string(); - let mut graph_result = graph_result; - cap_graph_result_to_utc_today(&mut graph_result, &utc_today); - + // Preserve local-calendar contributions here. The API validator owns the + // UTC+ timezone buffer; client-side UTC capping silently drops current-day + // usage for users east of UTC. See #318 and #360. // Drop cost-only rows the server would reject (Cursor historical exports // record per-request cost with empty token columns) and report what was // left out, so a single legacy charge can't block the whole submission. @@ -5124,21 +5344,20 @@ fn run_submit_command( }); if !status.is_success() { - eprintln!( - "\n {}", - format!( - "Error: {}", - body.error - .unwrap_or_else(|| "Submission failed".to_string()) - ) - .red() - ); + let error = body + .error + .clone() + .unwrap_or_else(|| "Submission failed".to_string()); + eprintln!("\n {}", format!("Error: {}", error).red()); if let Some(details) = body.details { for detail in details { eprintln!("{}", format!(" - {}", detail).bright_black()); } } println!(); + if mode == SubmitMode::Autosubmit { + return Err(anyhow::anyhow!(error)); + } std::process::exit(1); } @@ -5196,6 +5415,9 @@ fn run_submit_command( Err(err) => { eprintln!("\n {}", "Error: Failed to connect to server.".red()); eprintln!("{}\n", format!(" {}", err).bright_black()); + if mode == SubmitMode::Autosubmit { + return Err(anyhow::anyhow!("Failed to connect to server: {err}")); + } std::process::exit(1); } } @@ -5203,7 +5425,9 @@ fn run_submit_command( // Warm the TUI cache so the next `tokscale` launch is instant. // Detached subprocess so submit returns to the shell immediately on large // datasets — a full re-scan would otherwise block for tens of seconds. - spawn_warm_tui_cache_detached(); + if mode == SubmitMode::Interactive { + spawn_warm_tui_cache_detached(); + } Ok(()) } @@ -5388,6 +5612,19 @@ fn run_cursor_command(subcommand: CursorSubcommand) -> Result<()> { } } +fn run_codex_command(subcommand: CodexSubcommand) -> Result<()> { + match subcommand { + CodexSubcommand::Import { name } => commands::usage::codex::run_codex_import(name), + CodexSubcommand::Accounts { json } => commands::usage::codex::run_codex_accounts(json), + CodexSubcommand::Switch { name } => commands::usage::codex::run_codex_switch(&name), + CodexSubcommand::Remove { name } => commands::usage::codex::run_codex_remove(&name), + CodexSubcommand::Status { name, json } => { + commands::usage::codex::run_codex_status(name, json) + } + CodexSubcommand::Activity { json } => commands::codex_activity::run(json), + } +} + fn run_antigravity_command(subcommand: AntigravitySubcommand) -> Result<()> { match subcommand { AntigravitySubcommand::Sync => antigravity::run_antigravity_sync(), @@ -5763,7 +6000,7 @@ mod tests { use reqwest::StatusCode; use tokscale_core::{ calculate_summary, calculate_years, ClientContribution, DailyContribution, DailyTotals, - GraphMeta, GraphResult, TokenBreakdown, YearSummary, + GraphMeta, GraphResult, TokenBreakdown, }; #[test] @@ -5799,6 +6036,104 @@ mod tests { assert!(parse_variant_arg(Some("")).is_err()); } + #[test] + fn saturating_token_total_saturates_instead_of_overflowing() { + // tokscale-core (PR #823) clamps corrupt per-field token buckets to + // i64::MAX. The CLI display layer combines up to four such buckets + // (input/output/cache_read/cache_write) into row and grand totals; a + // plain `+` fold would panic in debug builds / wrap in release once + // two clamped buckets are combined. + assert_eq!(saturating_token_total(i64::MAX, i64::MAX, 0, 0), i64::MAX); + assert_eq!(saturating_token_total(i64::MAX, 1, i64::MAX, 1), i64::MAX); + // Real, non-overflowing counts still combine normally. + assert_eq!(saturating_token_total(10, 20, 30, 40), 100); + } + + #[test] + fn monthly_token_field_totals_saturate_across_entries() { + // MonthlyReport has no precomputed grand totals, so the display layer + // aggregates report.entries itself. Two entries each carrying a + // clamped (i64::MAX) input bucket must not overflow that aggregation. + let make = |input: i64| tokscale_core::MonthlyUsage { + month: "2026-07".to_string(), + models: vec![], + input, + output: 0, + cache_read: 0, + cache_write: 0, + message_count: 1, + cost: 0.0, + }; + let entries = vec![make(i64::MAX), make(i64::MAX)]; + let (total_input, total_output, total_cache_read, total_cache_write) = + monthly_token_field_totals(&entries); + assert_eq!(total_input, i64::MAX); + assert_eq!(total_output, 0); + assert_eq!(total_cache_read, 0); + assert_eq!(total_cache_write, 0); + } + + #[test] + fn model_entry_total_tokens_saturates_a_single_entrys_buckets() { + let entry = tokscale_core::ModelUsage { + client: "antigravity-cli".to_string(), + merged_clients: None, + workspace_key: None, + workspace_label: None, + session_id: None, + model: "gemini-3-pro".to_string(), + provider: "antigravity".to_string(), + input: i64::MAX, + output: 0, + cache_read: i64::MAX, + cache_write: 0, + reasoning: 0, + message_count: 1, + cost: 0.0, + performance: tokscale_core::ModelPerformance::default(), + }; + assert_eq!(model_entry_total_tokens(&entry), i64::MAX); + } + + #[test] + fn aggregate_model_report_performance_saturates_cross_entry_total() { + // model_entry_total_tokens already saturates each entry to i64::MAX; + // folding two such entries with plain `.sum()` would still overflow. + let make = || tokscale_core::ModelUsage { + client: "antigravity-cli".to_string(), + merged_clients: None, + workspace_key: None, + workspace_label: None, + session_id: None, + model: "gemini-3-pro".to_string(), + provider: "antigravity".to_string(), + input: i64::MAX, + output: 0, + cache_read: i64::MAX, + cache_write: 0, + reasoning: 0, + message_count: 1, + cost: 0.0, + performance: tokscale_core::ModelPerformance::default(), + }; + let entries = vec![make(), make()]; + // Must not panic (debug overflow) — the saturating fold caps at i64::MAX. + let performance = aggregate_model_report_performance(&entries); + assert_eq!(performance.timed_tokens, 0); + } + + #[test] + fn client_token_total_saturates_instead_of_overflowing() { + let tokens = TokenBreakdown { + input: i64::MAX, + output: 0, + cache_read: i64::MAX, + cache_write: 0, + reasoning: 0, + }; + assert_eq!(client_token_total(&tokens), i64::MAX); + } + fn token_breakdown(total_tokens: i64) -> TokenBreakdown { TokenBreakdown { input: total_tokens, @@ -5859,15 +6194,6 @@ mod tests { } } - fn year_summary(graph: &GraphResult, year: &str) -> YearSummary { - graph - .years - .iter() - .find(|entry| entry.year == year) - .cloned() - .unwrap() - } - // Tests below call `build_client_filter_with_defaults` directly with // an explicit `defaults` slice instead of `build_client_filter`, which // reads from `~/.config/tokscale/settings.json`. Reading host config @@ -5881,128 +6207,93 @@ mod tests { assert_eq!(build_client_filter_with_defaults(flags, &[]), None); } + /// The 32 per-client boolean flags removed in 4.0.0. After removal every + /// one of these must produce a clap parse error — backward-compat parsing + /// is intentionally gone (breaking change). Keep this list in sync with the + /// flags deleted from `ClientFlags`. + const REMOVED_LEGACY_CLIENT_FLAGS: [&str; 32] = [ + "opencode", + "claude", + "codex", + "copilot", + "gemini", + "cursor", + "amp", + "codebuff", + "droid", + "openclaw", + "hermes", + "pi", + "kimi", + "qwen", + "roocode", + "kilocode", + "kilo", + "mux", + "crush", + "goose", + "antigravity", + "zed", + "kiro", + "trae", + "warp", + "cline", + "gjc", + "grok", + "jcode", + "commandcode", + "micode", + "synthetic", + ]; + #[test] - fn test_build_client_filter_single_legacy_flag() { - let flags = ClientFlags { - opencode: true, - ..ClientFlags::default() - }; + fn test_removed_legacy_client_flags_now_error() { + for flag in REMOVED_LEGACY_CLIENT_FLAGS { + let arg = format!("--{flag}"); + let result = Cli::try_parse_from(["tokscale", arg.as_str()]); + assert!( + result.is_err(), + "expected `{arg}` to be rejected after removal, but it parsed" + ); + } + } + + #[test] + fn test_canonical_client_still_parses_for_removed_flag_names() { + // Every removed boolean flag name remains a valid `--client` value. + for flag in REMOVED_LEGACY_CLIENT_FLAGS { + let cli = Cli::try_parse_from(["tokscale", "--client", flag]) + .unwrap_or_else(|_| panic!("`--client {flag}` should parse")); + assert_eq!( + build_client_filter_with_defaults(cli.clients, &[]), + Some(vec![flag.to_string()]), + "`--client {flag}` should resolve to a single source" + ); + } + } + + #[test] + fn test_canonical_client_parses_single_and_multi() { + let cli = Cli::try_parse_from(["tokscale", "--client", "opencode"]).expect("parse ok"); assert_eq!( - build_client_filter_with_defaults(flags, &[]), + build_client_filter_with_defaults(cli.clients, &[]), Some(vec!["opencode".to_string()]) ); - } - #[test] - fn test_build_client_filter_multiple_legacy_flags_preserve_order() { - let flags = ClientFlags { - opencode: true, - claude: true, - pi: true, - ..ClientFlags::default() - }; - // Legacy iteration order is the declaration order in `legacy[]`, - // not the order the user typed flags on the command line. This is - // a deliberate trade-off: legacy flags are deprecated, and the - // canonical `--client a,b,c` form preserves user order. + let cli = + Cli::try_parse_from(["tokscale", "--client", "opencode,claude"]).expect("parse ok"); assert_eq!( - build_client_filter_with_defaults(flags, &[]), - Some(vec![ - "opencode".to_string(), - "claude".to_string(), - "pi".to_string() - ]) + build_client_filter_with_defaults(cli.clients, &[]), + Some(vec!["opencode".to_string(), "claude".to_string()]) ); - } - #[test] - fn test_build_client_filter_synthetic_only_legacy() { - let flags = ClientFlags { - synthetic: true, - ..ClientFlags::default() - }; + let cli = Cli::try_parse_from(["tokscale", "--client", "synthetic"]).expect("parse ok"); assert_eq!( - build_client_filter_with_defaults(flags, &[]), + build_client_filter_with_defaults(cli.clients, &[]), Some(vec!["synthetic".to_string()]) ); } - #[test] - fn test_build_client_filter_all_legacy_flags() { - let flags = ClientFlags { - opencode: true, - claude: true, - codex: true, - copilot: true, - gemini: true, - cursor: true, - amp: true, - codebuff: true, - droid: true, - openclaw: true, - hermes: true, - pi: true, - kimi: true, - qwen: true, - roocode: true, - kilocode: true, - kilo: true, - mux: true, - crush: true, - goose: true, - antigravity: true, - zed: true, - kiro: true, - trae: true, - warp: true, - cline: true, - gjc: true, - synthetic: true, - ..ClientFlags::default() - }; - let result = build_client_filter_with_defaults(flags, &[]); - assert!(result.is_some()); - let sources = result.unwrap(); - // ClientId::COUNT does not include synthetic, but ClientFilter does. - let expected_len = tokscale_core::ClientId::iter().count() + 1; - assert_eq!(sources.len(), expected_len); - for required in [ - "opencode", - "claude", - "codex", - "copilot", - "gemini", - "cursor", - "amp", - "codebuff", - "droid", - "openclaw", - "hermes", - "pi", - "kimi", - "qwen", - "roocode", - "kilocode", - "kilo", - "mux", - "crush", - "goose", - "antigravity", - "zed", - "kiro", - "trae", - "warp", - "cline", - "gjc", - "synthetic", - ] { - assert!( - sources.contains(&required.to_string()), - "missing client filter id: {required}" - ); - } - } - #[test] fn test_build_client_filter_canonical_clients_preserve_user_order() { // `--client claude,opencode,pi` should keep user-typed order so @@ -6013,7 +6304,6 @@ mod tests { ClientFilter::Opencode, ClientFilter::Pi, ], - ..ClientFlags::default() }; assert_eq!( build_client_filter_with_defaults(flags, &[]), @@ -6033,24 +6323,6 @@ mod tests { ClientFilter::Claude, ClientFilter::Opencode, ], - ..ClientFlags::default() - }; - assert_eq!( - build_client_filter_with_defaults(flags, &[]), - Some(vec!["claude".to_string(), "opencode".to_string()]) - ); - } - - #[test] - fn test_build_client_filter_canonical_and_legacy_dedup() { - // Mixing canonical `--client claude` with legacy `--claude` must not - // double-list claude. Canonical entries come first, legacy fills in - // anything missing. - let flags = ClientFlags { - clients: vec![ClientFilter::Claude], - opencode: true, - claude: true, - ..ClientFlags::default() }; assert_eq!( build_client_filter_with_defaults(flags, &[]), @@ -6256,7 +6528,6 @@ mod tests { // give me X" not "I asked for X but you also added Y from settings". let flags = ClientFlags { clients: vec![ClientFilter::Codex], - ..ClientFlags::default() }; let defaults = vec!["opencode".to_string(), "claude".to_string()]; assert_eq!( @@ -6266,13 +6537,11 @@ mod tests { } #[test] - fn test_build_client_filter_legacy_flag_overrides_defaults() { - // Legacy flags also count as "user passed something" → defaults - // ignored. Otherwise upgrading a script that uses --opencode - // would surprise users with extra clients from settings. + fn test_build_client_filter_canonical_flag_overrides_defaults() { + // A canonical `--client` value counts as "user passed something" → + // defaults ignored. CLI flags always win over settings.json. let flags = ClientFlags { - opencode: true, - ..ClientFlags::default() + clients: vec![ClientFilter::Opencode], }; let defaults = vec!["claude".to_string()]; assert_eq!( @@ -6395,14 +6664,6 @@ mod tests { assert!(show_clients); } - #[test] - fn test_client_flags_legacy_still_parses() { - // Legacy `--claude` keeps working even though it is hidden in --help. - let cli = Cli::try_parse_from(["tokscale", "--claude"]).expect("parse ok"); - assert!(cli.clients.claude); - assert!(cli.clients.clients.is_empty()); - } - #[test] fn test_client_flag_accepts_uppercase() { let cli = @@ -6423,15 +6684,6 @@ mod tests { assert!(Cli::try_parse_from(["tokscale", "--client", ""]).is_err()); } - #[test] - fn test_legacy_bool_flag_rejects_duplicates() { - let result = Cli::try_parse_from(["tokscale", "--opencode", "--opencode"]); - assert!( - result.is_err(), - "clap rejects duplicated boolean flags by default; if this changes, document it explicitly" - ); - } - #[test] fn test_default_submit_clients_excludes_crush() { let clients = default_submit_clients(); @@ -6505,6 +6757,62 @@ mod tests { assert!(matches!(cli.command, Some(Commands::DeleteSubmittedData))); } + #[test] + fn test_codex_activity_command_parses() { + let cli = Cli::try_parse_from(["tokscale", "codex", "activity", "--json"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Codex { + subcommand: CodexSubcommand::Activity { json: true } + }) + )); + } + + #[test] + fn test_autosubmit_commands_parse() { + let cli = Cli::try_parse_from([ + "tokscale", + "autosubmit", + "enable", + "--interval", + "2h", + "--client", + "opencode,claude", + "--week", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Autosubmit { + subcommand: commands::autosubmit::AutosubmitSubcommand::Enable(_) + }) + )); + + let cli = Cli::try_parse_from(["tokscale", "autosubmit", "status", "--json"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Autosubmit { + subcommand: commands::autosubmit::AutosubmitSubcommand::Status { json: true } + }) + )); + + let cli = Cli::try_parse_from(["tokscale", "autosubmit", "run", "--force"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Autosubmit { + subcommand: commands::autosubmit::AutosubmitSubcommand::Run { force: true } + }) + )); + + let cli = Cli::try_parse_from(["tokscale", "autosubmit", "disable"]).unwrap(); + assert!(matches!( + cli.command, + Some(Commands::Autosubmit { + subcommand: commands::autosubmit::AutosubmitSubcommand::Disable + }) + )); + } + #[test] fn test_login_token_option_parses() { let cli = Cli::try_parse_from(["tokscale", "login", "--token", "tt_ci_token"]).unwrap(); @@ -6544,20 +6852,18 @@ mod tests { #[test] fn test_build_date_filter_custom_range() { - let (since, until) = build_date_filter( - false, - false, - false, - Some("2024-01-01".to_string()), - Some("2024-12-31".to_string()), - ); + let (since, until) = build_date_filter(&DateRangeFlags { + since: Some("2024-01-01".to_string()), + until: Some("2024-12-31".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(since, Some("2024-01-01".to_string())); assert_eq!(until, Some("2024-12-31".to_string())); } #[test] fn test_build_date_filter_no_filters() { - let (since, until) = build_date_filter(false, false, false, None, None); + let (since, until) = build_date_filter(&DateRangeFlags::default()); assert_eq!(since, None); assert_eq!(until, None); } @@ -6565,15 +6871,41 @@ mod tests { #[test] fn test_build_date_filter_today_uses_provided_local_date() { let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap(); - let (since, until) = build_date_filter_for_date(true, false, false, None, None, today); + let (since, until) = build_date_filter_for_date( + &DateRangeFlags { + today: true, + ..DateRangeFlags::default() + }, + today, + ); assert_eq!(since, Some("2026-03-08".to_string())); assert_eq!(until, Some("2026-03-08".to_string())); } + #[test] + fn test_build_date_filter_yesterday_uses_provided_local_date() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap(); + let (since, until) = build_date_filter_for_date( + &DateRangeFlags { + yesterday: true, + ..DateRangeFlags::default() + }, + today, + ); + assert_eq!(since, Some("2026-03-07".to_string())); + assert_eq!(until, Some("2026-03-07".to_string())); + } + #[test] fn test_build_date_filter_week_uses_provided_local_date() { let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap(); - let (since, until) = build_date_filter_for_date(false, true, false, None, None, today); + let (since, until) = build_date_filter_for_date( + &DateRangeFlags { + week: true, + ..DateRangeFlags::default() + }, + today, + ); assert_eq!(since, Some("2026-03-02".to_string())); assert_eq!(until, Some("2026-03-08".to_string())); } @@ -6581,41 +6913,125 @@ mod tests { #[test] fn test_build_date_filter_month_uses_provided_local_date() { let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 8).unwrap(); - let (since, until) = build_date_filter_for_date(false, false, true, None, None, today); + let (since, until) = build_date_filter_for_date( + &DateRangeFlags { + month: true, + ..DateRangeFlags::default() + }, + today, + ); assert_eq!(since, Some("2026-03-01".to_string())); assert_eq!(until, Some("2026-03-08".to_string())); } #[test] fn test_normalize_year_filter_with_year() { - let year = normalize_year_filter(false, false, false, Some("2024".to_string())); + let year = normalize_year_filter(&DateRangeFlags { + year: Some("2024".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(year, Some("2024".to_string())); } #[test] fn test_normalize_year_filter_with_today() { - let year = normalize_year_filter(true, false, false, Some("2024".to_string())); + let year = normalize_year_filter(&DateRangeFlags { + today: true, + year: Some("2024".to_string()), + ..DateRangeFlags::default() + }); + assert_eq!(year, None); + } + + #[test] + fn test_normalize_year_filter_with_yesterday() { + let year = normalize_year_filter(&DateRangeFlags { + yesterday: true, + year: Some("2024".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(year, None); } #[test] fn test_normalize_year_filter_with_week() { - let year = normalize_year_filter(false, true, false, Some("2024".to_string())); + let year = normalize_year_filter(&DateRangeFlags { + week: true, + year: Some("2024".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(year, None); } #[test] fn test_normalize_year_filter_with_month() { - let year = normalize_year_filter(false, false, true, Some("2024".to_string())); + let year = normalize_year_filter(&DateRangeFlags { + month: true, + year: Some("2024".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(year, None); } #[test] fn test_normalize_year_filter_no_year() { - let year = normalize_year_filter(false, false, false, None); + let year = normalize_year_filter(&DateRangeFlags::default()); assert_eq!(year, None); } + /// Parses `args` expecting failure; panics if parsing unexpectedly + /// succeeds. Avoids `unwrap_err()` since `Cli` does not derive `Debug`. + fn expect_parse_error(args: &[&str]) -> clap::Error { + match Cli::try_parse_from(args) { + Ok(_) => panic!("expected `{}` to fail to parse", args.join(" ")), + Err(err) => err, + } + } + + #[test] + fn test_date_shortcut_flags_conflict() { + let err = expect_parse_error(&["tokscale", "--today", "--yesterday"]); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + + let err = expect_parse_error(&["tokscale", "--week", "--month"]); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn test_date_shortcut_conflicts_with_since_until_year() { + let err = expect_parse_error(&["tokscale", "--today", "--since", "2024-01-01"]); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + + let err = expect_parse_error(&["tokscale", "--week", "--until", "2024-12-31"]); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + + let err = expect_parse_error(&["tokscale", "--month", "--year", "2024"]); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn test_date_shortcut_conflict_applies_to_subcommands() { + let err = expect_parse_error(&["tokscale", "models", "--today", "--yesterday"]); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn test_since_until_year_still_combine() { + let cli = Cli::try_parse_from([ + "tokscale", + "--since", + "2024-01-01", + "--until", + "2024-12-31", + "--year", + "2024", + ]) + .unwrap(); + assert_eq!(cli.date.since.as_deref(), Some("2024-01-01")); + assert_eq!(cli.date.until.as_deref(), Some("2024-12-31")); + assert_eq!(cli.date.year.as_deref(), Some("2024")); + } + #[test] fn test_format_tokens_with_commas_small() { assert_eq!(format_tokens_with_commas(123), "123"); @@ -6727,6 +7143,11 @@ mod tests { assert_eq!(capitalize_client("pi"), "Pi"); } + #[test] + fn test_capitalize_client_jcode() { + assert_eq!(capitalize_client("jcode"), "Jcode"); + } + #[test] fn test_capitalize_client_unknown() { assert_eq!(capitalize_client("unknown"), "unknown"); @@ -6734,72 +7155,84 @@ mod tests { #[test] fn test_get_date_range_label_today() { - let label = get_date_range_label(true, false, false, &None, &None, &None); + let label = get_date_range_label(&DateRangeFlags { + today: true, + ..DateRangeFlags::default() + }); assert_eq!(label, Some("Today".to_string())); } + #[test] + fn test_get_date_range_label_yesterday() { + let label = get_date_range_label(&DateRangeFlags { + yesterday: true, + ..DateRangeFlags::default() + }); + assert_eq!(label, Some("Yesterday".to_string())); + } + #[test] fn test_get_date_range_label_week() { - let label = get_date_range_label(false, true, false, &None, &None, &None); + let label = get_date_range_label(&DateRangeFlags { + week: true, + ..DateRangeFlags::default() + }); assert_eq!(label, Some("Last 7 days".to_string())); } #[test] fn test_get_date_range_label_month_uses_provided_local_date() { let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); - let label = get_date_range_label_for_date(false, false, true, &None, &None, &None, today); + let label = get_date_range_label_for_date( + &DateRangeFlags { + month: true, + ..DateRangeFlags::default() + }, + today, + ); assert_eq!(label, Some("March 2026".to_string())); } #[test] fn test_get_date_range_label_year() { - let label = - get_date_range_label(false, false, false, &None, &None, &Some("2024".to_string())); + let label = get_date_range_label(&DateRangeFlags { + year: Some("2024".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(label, Some("2024".to_string())); } #[test] fn test_get_date_range_label_custom_since() { - let label = get_date_range_label( - false, - false, - false, - &Some("2024-01-01".to_string()), - &None, - &None, - ); + let label = get_date_range_label(&DateRangeFlags { + since: Some("2024-01-01".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(label, Some("from 2024-01-01".to_string())); } #[test] fn test_get_date_range_label_custom_until() { - let label = get_date_range_label( - false, - false, - false, - &None, - &Some("2024-12-31".to_string()), - &None, - ); + let label = get_date_range_label(&DateRangeFlags { + until: Some("2024-12-31".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(label, Some("to 2024-12-31".to_string())); } #[test] fn test_get_date_range_label_custom_range() { - let label = get_date_range_label( - false, - false, - false, - &Some("2024-01-01".to_string()), - &Some("2024-12-31".to_string()), - &None, - ); + let label = get_date_range_label(&DateRangeFlags { + since: Some("2024-01-01".to_string()), + until: Some("2024-12-31".to_string()), + ..DateRangeFlags::default() + }); assert_eq!(label, Some("from 2024-01-01 to 2024-12-31".to_string())); } #[test] fn test_get_date_range_label_none() { - let label = get_date_range_label(false, false, false, &None, &None, &None); + let label = get_date_range_label(&DateRangeFlags::default()); assert_eq!(label, None); } @@ -6882,79 +7315,6 @@ mod tests { assert_eq!(forward1, forward2); } - #[test] - fn test_cap_graph_result_to_utc_today_recalculates_all_derived_fields() { - let mut graph = graph_result_with_contributions(vec![ - daily_contribution("2026-12-30", 10, 1.25, "codex", "model-a"), - daily_contribution("2026-12-31", 20, 2.50, "codex", "model-b"), - daily_contribution("2027-01-01", 30, 3.75, "cursor", "model-c"), - ]); - - let changed = cap_graph_result_to_utc_today(&mut graph, "2026-12-31"); - - assert!(changed); - assert_eq!(graph.meta.date_range_start, "2026-12-30"); - assert_eq!(graph.meta.date_range_end, "2026-12-31"); - assert_eq!(graph.contributions.len(), 2); - assert_eq!(graph.summary.total_tokens, 30); - assert_eq!(graph.summary.total_cost, 3.75); - assert_eq!(graph.summary.total_days, 2); - assert_eq!(graph.summary.active_days, 2); - assert_eq!(graph.summary.clients, vec!["codex".to_string()]); - assert_eq!( - graph.summary.models, - vec!["model-a".to_string(), "model-b".to_string()] - ); - assert_eq!(graph.years.len(), 1); - assert_eq!(year_summary(&graph, "2026").total_tokens, 30); - } - - #[test] - fn test_cap_graph_result_to_utc_today_clears_empty_post_cap_state() { - let mut graph = graph_result_with_contributions(vec![daily_contribution( - "2027-01-01", - 30, - 3.75, - "cursor", - "model-c", - )]); - - let changed = cap_graph_result_to_utc_today(&mut graph, "2026-12-31"); - - assert!(changed); - assert!(graph.contributions.is_empty()); - assert_eq!(graph.meta.date_range_start, ""); - assert_eq!(graph.meta.date_range_end, ""); - assert_eq!(graph.summary.total_tokens, 0); - assert_eq!(graph.summary.total_cost, 0.0); - assert_eq!(graph.summary.total_days, 0); - assert_eq!(graph.summary.active_days, 0); - assert!(graph.summary.clients.is_empty()); - assert!(graph.summary.models.is_empty()); - assert!(graph.years.is_empty()); - } - - #[test] - fn test_cap_graph_result_to_utc_today_is_noop_when_all_dates_are_in_range() { - let mut graph = graph_result_with_contributions(vec![ - daily_contribution("2026-12-30", 10, 1.25, "codex", "model-a"), - daily_contribution("2026-12-31", 20, 2.50, "codex", "model-b"), - ]); - let original_summary = graph.summary.clone(); - let original_years = graph.years.clone(); - - let changed = cap_graph_result_to_utc_today(&mut graph, "2026-12-31"); - - assert!(!changed); - assert_eq!(graph.meta.date_range_start, "2026-12-30"); - assert_eq!(graph.meta.date_range_end, "2026-12-31"); - assert_eq!(graph.summary.total_tokens, original_summary.total_tokens); - assert_eq!(graph.summary.total_cost, original_summary.total_cost); - assert_eq!(graph.summary.clients, original_summary.clients); - assert_eq!(graph.summary.models, original_summary.models); - assert_eq!(graph.years.len(), original_years.len()); - } - fn client_contribution( client: &str, model_id: &str, @@ -7289,6 +7649,21 @@ mod tests { assert!(Cli::try_parse_from(["tokscale", "cursor", "sync", "--json"]).is_ok()); } + #[test] + fn clap_accepts_codex_account_commands() { + assert!(Cli::try_parse_from(["tokscale", "codex", "import", "--name", "work"]).is_ok()); + assert!(Cli::try_parse_from(["tokscale", "codex", "accounts"]).is_ok()); + assert!(Cli::try_parse_from(["tokscale", "codex", "accounts", "--json"]).is_ok()); + assert!(Cli::try_parse_from(["tokscale", "codex", "switch", "work"]).is_ok()); + assert!(Cli::try_parse_from(["tokscale", "codex", "remove", "work"]).is_ok()); + assert!(Cli::try_parse_from(["tokscale", "codex", "status"]).is_ok()); + assert!(Cli::try_parse_from(["tokscale", "codex", "status", "--name", "work"]).is_ok()); + assert!( + Cli::try_parse_from(["tokscale", "codex", "status", "--name", "work", "--json"]) + .is_ok() + ); + } + #[test] fn clap_accepts_warp_status_and_sync_commands() { assert!(Cli::try_parse_from(["tokscale", "warp", "status"]).is_ok()); @@ -7314,6 +7689,23 @@ mod tests { ); } + #[test] + fn client_filter_round_trips_grok() { + assert_eq!( + ClientFilter::from_filter_str("grok"), + Some(ClientFilter::Grok) + ); + assert_eq!(ClientFilter::Grok.as_filter_str(), "grok"); + assert_eq!( + ClientFilter::Grok.to_client_id(), + Some(tokscale_core::ClientId::Grok) + ); + assert_eq!( + ClientFilter::from_client_id(tokscale_core::ClientId::Grok), + ClientFilter::Grok + ); + } + #[test] fn default_submit_clients_excludes_warp_aggregate_source() { let clients = default_submit_clients(); diff --git a/crates/tokscale-cli/src/tui/app.rs b/crates/tokscale-cli/src/tui/app.rs index e0178d263..e9f885d09 100644 --- a/crates/tokscale-cli/src/tui/app.rs +++ b/crates/tokscale-cli/src/tui/app.rs @@ -4,22 +4,28 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use anyhow::Result; -use chrono::NaiveDate; +use chrono::{Datelike, NaiveDate}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; use tokscale_core::ClientId; +use crate::commands::usage::{UsageFetchReport, UsageOutput}; use crate::ClientFilter; use ratatui::style::Color; +use super::codex_login::{ + cancel_codex_login_child, run_codex_login_worker, CodexLoginChildSlot, CodexLoginEvent, + CodexLoginOutcome, +}; use super::data::{ - AgentUsage, DailyUsage, DataLoader, HourlyUsage, MinutelyUsage, ModelUsage, TokenBreakdown, - UsageData, + AgentUsage, DailyUsage, DataLoader, HourlyUsage, MinutelyUsage, ModelUsage, MonthlyUsage, + TokenBreakdown, UsageData, }; +use super::privacy::looks_like_email; use super::settings::Settings; use super::themes::{Theme, ThemeName}; -use super::ui::dialog::{ClientPickerDialog, DialogStack}; +use super::ui::dialog::{ClientPickerDialog, ConfirmDialog, DialogStack}; use super::ui::widgets::{get_model_color, get_provider_from_model, get_provider_shade}; /// Configuration for TUI initialization @@ -34,6 +40,18 @@ pub struct TuiConfig { pub initial_tab: Option, } +#[cfg(not(test))] +fn default_usage_fetcher() -> UsageFetchReport { + crate::commands::usage::fetch_all_report_with_intent( + crate::commands::usage::UsageFetchIntent::TuiSurface, + ) +} + +#[cfg(test)] +fn test_usage_fetcher() -> UsageFetchReport { + UsageFetchReport::default() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tab { Overview, @@ -42,6 +60,7 @@ pub enum Tab { Daily, Hourly, Minutely, + Monthly, Stats, Agents, } @@ -55,6 +74,7 @@ impl Tab { Tab::Daily, Tab::Hourly, Tab::Minutely, + Tab::Monthly, Tab::Stats, Tab::Agents, ] @@ -68,6 +88,7 @@ impl Tab { Tab::Daily => "Daily", Tab::Hourly => "Hourly", Tab::Minutely => "Minutely", + Tab::Monthly => "Monthly", Tab::Stats => "Stats", Tab::Agents => "Agents", } @@ -81,6 +102,7 @@ impl Tab { Tab::Daily => "Day", Tab::Hourly => "Hr", Tab::Minutely => "Min", + Tab::Monthly => "Mon", Tab::Stats => "Sta", Tab::Agents => "Agt", } @@ -93,7 +115,8 @@ impl Tab { Tab::Models => Tab::Daily, Tab::Daily => Tab::Hourly, Tab::Hourly => Tab::Minutely, - Tab::Minutely => Tab::Stats, + Tab::Minutely => Tab::Monthly, + Tab::Monthly => Tab::Stats, Tab::Stats => Tab::Agents, Tab::Agents => Tab::Overview, } @@ -107,7 +130,8 @@ impl Tab { Tab::Daily => Tab::Models, Tab::Hourly => Tab::Daily, Tab::Minutely => Tab::Hourly, - Tab::Stats => Tab::Minutely, + Tab::Monthly => Tab::Minutely, + Tab::Stats => Tab::Monthly, Tab::Agents => Tab::Stats, } } @@ -161,6 +185,93 @@ pub enum ClickAction { Tab(Tab), Sort(SortField), GraphCell { week: usize, day: usize }, + UsageRefresh, + CodexStartLogin, + CodexDismissLogin, + UsageSelect { index: usize }, + UsageToggleEmailPrivacy, + CodexUseAccount { account_id: String }, + CodexRemoveAccount { account_id: String }, + CodexResetAccount { account_id: String }, +} + +fn codex_reset_outcome_label( + result: &crate::commands::usage::codex::RateLimitResetConsumeResult, +) -> String { + match result.code.as_str() { + "reset" => match result.windows_reset { + Some(1) => "reset 1 window".to_string(), + Some(count) => format!("reset {count} windows"), + None => "reset complete".to_string(), + }, + "already_redeemed" => "credit already redeemed".to_string(), + "nothing_to_reset" => "nothing to reset".to_string(), + "no_credit" => "no credit available".to_string(), + "" => "unknown response".to_string(), + other => other.to_string(), + } +} + +fn short_account_id(account_id: &str) -> String { + let id = account_id.trim(); + if id.is_empty() { + return "Account unknown".to_string(); + } + + let char_count = id.chars().count(); + if char_count <= 12 { + return format!("Account {id}"); + } + + let head: String = id.chars().take(6).collect(); + let tail: String = id + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("Account {head}...{tail}") +} + +fn compare_codex_usage_outputs(a: &UsageOutput, b: &UsageOutput) -> std::cmp::Ordering { + let active_order = codex_usage_is_active(b).cmp(&codex_usage_is_active(a)); + if active_order != std::cmp::Ordering::Equal { + return active_order; + } + + codex_usage_sort_key(a) + .cmp(&codex_usage_sort_key(b)) + .then_with(|| codex_usage_account_id(a).cmp(codex_usage_account_id(b))) +} + +fn codex_usage_is_active(output: &UsageOutput) -> bool { + output + .account + .as_ref() + .is_some_and(|account| account.is_active) +} + +fn codex_usage_sort_key(output: &UsageOutput) -> String { + output + .account + .as_ref() + .map(|account| { + account + .label_name() + .unwrap_or(account.id.as_str()) + .to_lowercase() + }) + .unwrap_or_else(|| output.display_name().to_lowercase()) +} + +fn codex_usage_account_id(output: &UsageOutput) -> &str { + output + .account + .as_ref() + .map(|account| account.id.as_str()) + .unwrap_or_default() } struct MinutelySortCache { @@ -199,11 +310,16 @@ pub struct App { daily_list_selected_index: usize, daily_list_scroll_offset: usize, + pub selected_monthly_detail_month: Option, + monthly_list_selected_index: usize, + monthly_list_scroll_offset: usize, + pub selected_graph_cell: Option<(usize, usize)>, pub stats_breakdown_total_lines: usize, pub auto_refresh: bool, pub auto_refresh_interval: Duration, + pub last_auto_refresh: Instant, pub last_refresh: Instant, pub status_message: Option, @@ -229,9 +345,34 @@ pub struct App { pub model_shade_map: HashMap, pub subscription_usage: Vec, + pub usage_fetch_diagnostics: Vec, + confirmed_codex_use_account_id: Rc>>, + confirmed_codex_remove_account_id: Rc>>, + confirmed_codex_reset_account_id: Rc>>, + pub hide_usage_emails: bool, + pub codex_login_lines: Vec, + pub(crate) codex_login_outcome: Option, pub usage_fetch_attempted: bool, - usage_rx: Option>>, + usage_rx: Option>, + usage_fetch_preserve_status: bool, + usage_fetcher: fn() -> UsageFetchReport, + codex_reset_rx: Option< + std::sync::mpsc::Receiver< + Result, + >, + >, + codex_login_rx: Option>, + codex_login_child: Option, + + /// Server-side stats aggregated across all of the user's devices + /// (`GET /api/me/stats`). `None` means local-only: logged out, offline, + /// or the fetch has not completed yet. + pub remote_stats: Option, + remote_stats_rx: Option>, + /// Throttles background refresh attempts so a failing fetch (offline, + /// expired token) is not retried on every tick. + remote_stats_last_attempt: Option, data_version: u64, minutely_sort_cache: RefCell>, @@ -240,10 +381,14 @@ pub struct App { impl App { pub fn new_with_cached_data(config: TuiConfig, cached_data: Option) -> Result { let settings = Settings::load(); - let theme_name: ThemeName = config - .theme - .parse() - .unwrap_or_else(|_| settings.theme_name()); + let theme_name: ThemeName = if config.theme.is_empty() { + settings.theme_name() + } else { + config + .theme + .parse() + .unwrap_or_else(|_| settings.theme_name()) + }; let theme = Theme::from_name_for_current_terminal(theme_name); let enabled_clients: HashSet = if let Some(ref cli_clients) = config.clients { @@ -286,6 +431,9 @@ impl App { let has_data = !data.models.is_empty(); let dialog_stack = DialogStack::new(theme.clone()); let dialog_needs_reload = Rc::new(RefCell::new(false)); + let confirmed_codex_use_account_id = Rc::new(RefCell::new(None)); + let confirmed_codex_remove_account_id = Rc::new(RefCell::new(None)); + let confirmed_codex_reset_account_id = Rc::new(RefCell::new(None)); let requested_tab = config.initial_tab.unwrap_or(Tab::Overview); let current_tab = if Self::tab_visible(&settings, requested_tab) { requested_tab @@ -313,10 +461,14 @@ impl App { selected_daily_detail_date: None, daily_list_selected_index: 0, daily_list_scroll_offset: 0, + selected_monthly_detail_month: None, + monthly_list_selected_index: 0, + monthly_list_scroll_offset: 0, selected_graph_cell: None, stats_breakdown_total_lines: 0, auto_refresh, auto_refresh_interval, + last_auto_refresh: Instant::now(), last_refresh: Instant::now(), status_message: if has_data { Some("Loaded from cache".to_string()) @@ -344,12 +496,37 @@ impl App { Vec::new() } }, + usage_fetch_diagnostics: Vec::new(), + confirmed_codex_use_account_id, + confirmed_codex_remove_account_id, + confirmed_codex_reset_account_id, + hide_usage_emails: true, + codex_login_lines: Vec::new(), + codex_login_outcome: None, usage_fetch_attempted: false, usage_rx: None, + usage_fetch_preserve_status: false, + usage_fetcher: { + #[cfg(test)] + { + test_usage_fetcher + } + #[cfg(not(test))] + { + default_usage_fetcher + } + }, + codex_reset_rx: None, + codex_login_rx: None, + codex_login_child: None, + remote_stats: None, + remote_stats_rx: None, + remote_stats_last_attempt: None, data_version: 0, minutely_sort_cache: RefCell::new(None), }; app.build_model_shade_map(); + app.maybe_fetch_usage_on_entry(); Ok(app) } @@ -376,6 +553,15 @@ impl App { } } + // Same for Monthly-detail mode: exit if the month disappeared. + if let Some(ref month) = self.selected_monthly_detail_month { + if !self.data.monthly.iter().any(|m| &m.month == month) { + self.selected_monthly_detail_month = None; + self.selected_index = self.monthly_list_selected_index; + self.scroll_offset = self.monthly_list_scroll_offset; + } + } + self.clamp_selection(); } @@ -384,11 +570,10 @@ impl App { } pub fn model_color_for(&self, provider: &str, model: &str) -> Color { - let provider = if provider.is_empty() || provider.contains(", ") { - get_provider_from_model(model) - } else { - provider - }; + // Same key derivation as `build_model_shade_map`, so gateway providers + // (e.g. `github-copilot`) resolve to the model's own vendor ramp and the + // lookup never misses into the fallback for a model we've ranked. + let provider = super::colors::provider_color_key(provider, model); let lookup_key = super::colors::model_shade_key(provider, model); let color = self .model_shade_map @@ -432,11 +617,17 @@ impl App { } } - if self.auto_refresh - && !self.background_loading - && self.last_refresh.elapsed() >= self.auto_refresh_interval - { - self.needs_reload = true; + if self.auto_refresh && self.last_auto_refresh.elapsed() >= self.auto_refresh_interval { + if self.current_tab == Tab::Usage { + self.last_auto_refresh = Instant::now(); + // Auto-refresh is a silent background poll, not a user action, + // so it must not overwrite the current status message (e.g. a + // Codex reset result) with "Fetching usage data...". + self.fetch_subscription_usage_preserving_status(); + } else if !self.background_loading { + self.last_auto_refresh = Instant::now(); + self.needs_reload = true; + } } if *self.dialog_needs_reload.borrow() { @@ -447,40 +638,165 @@ impl App { // Poll background usage fetch if let Some(ref rx) = self.usage_rx { match rx.try_recv() { - Ok(results) => { + Ok(report) => { + let preserve_status = self.usage_fetch_preserve_status; + self.usage_fetch_preserve_status = false; self.usage_rx = None; - self.subscription_usage = results; + self.subscription_usage = report.outputs; + self.usage_fetch_diagnostics = report.diagnostics; if !self.subscription_usage.is_empty() { crate::commands::usage::save_cache(&self.subscription_usage); - self.status_message = Some("Usage data loaded".into()); + if !preserve_status { + self.status_message = Some(self.usage_loaded_status()); + } } else { crate::commands::usage::clear_cache(); - self.status_message = Some("No usage data available".into()); + if !preserve_status { + self.status_message = Some(self.usage_empty_status()); + } + } + if !preserve_status { + self.status_message_time = Some(std::time::Instant::now()); } - self.status_message_time = Some(std::time::Instant::now()); } Err(std::sync::mpsc::TryRecvError::Disconnected) => { + let preserve_status = self.usage_fetch_preserve_status; + self.usage_fetch_preserve_status = false; self.usage_rx = None; - self.status_message = Some("Usage fetch failed".into()); + if !preserve_status { + self.status_message = Some("Usage fetch failed".into()); + self.status_message_time = Some(std::time::Instant::now()); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + } + + if let Some(ref rx) = self.codex_reset_rx { + match rx.try_recv() { + Ok(Ok(result)) => { + self.codex_reset_rx = None; + self.status_message = Some(format!( + "Codex reset credit: {}", + codex_reset_outcome_label(&result) + )); + self.status_message_time = Some(std::time::Instant::now()); + self.fetch_subscription_usage_preserving_status(); + } + Ok(Err(error)) => { + self.codex_reset_rx = None; + self.status_message = Some(format!("Codex reset failed: {error}")); + self.status_message_time = Some(std::time::Instant::now()); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.codex_reset_rx = None; + self.status_message = Some("Codex reset failed".into()); self.status_message_time = Some(std::time::Instant::now()); } Err(std::sync::mpsc::TryRecvError::Empty) => {} } } + + self.poll_remote_stats(); + self.maybe_refresh_remote_stats(); + + self.poll_codex_login(); + } + + fn poll_codex_login(&mut self) { + let mut events = Vec::new(); + let mut disconnected = false; + + if let Some(rx) = &self.codex_login_rx { + loop { + match rx.try_recv() { + Ok(event) => events.push(event), + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + + let mut finished = false; + for event in events { + match event { + CodexLoginEvent::Output(line) => { + self.codex_login_lines.push(line); + const MAX_LOGIN_LINES: usize = 12; + if self.codex_login_lines.len() > MAX_LOGIN_LINES { + let drain_count = self.codex_login_lines.len() - MAX_LOGIN_LINES; + self.codex_login_lines.drain(0..drain_count); + } + } + CodexLoginEvent::Finished(outcome) => { + finished = true; + match &outcome { + CodexLoginOutcome::Imported(info) => { + let display = info.label.as_deref().unwrap_or(&info.id); + self.set_status(&format!("Imported Codex account: {display}")); + } + CodexLoginOutcome::Failed(error) => { + self.set_status(&format!("Codex login failed: {error}")); + } + } + self.codex_login_outcome = Some(outcome); + } + } + } + + if disconnected && !finished && self.codex_login_outcome.is_none() { + self.codex_login_outcome = Some(CodexLoginOutcome::Failed( + "login worker stopped".to_string(), + )); + self.set_status("Codex login failed: login worker stopped"); + finished = true; + } + + if finished { + self.codex_login_rx = None; + self.codex_login_child = None; + if matches!( + self.codex_login_outcome, + Some(CodexLoginOutcome::Imported(_)) + ) { + self.codex_login_lines.clear(); + self.codex_login_outcome = None; + self.refresh_usage(); + } + } } pub fn handle_key_event(&mut self, key: KeyEvent) -> bool { - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + // Remap the produced character to its US-QWERTY physical position so + // single-letter hotkeys keep working under non-Latin layouts (Russian, + // Greek, …). Modifiers and non-char keys are unaffected. Dialogs still + // receive the raw `key.code` and normalize per-field, since some of + // them (e.g. the picker filter) accept literal text input. + let code = crate::tui::keymap::normalize_hotkey(key.code); + + if code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { self.should_quit = true; return true; } if self.dialog_stack.is_active() { self.dialog_stack.handle_key(key.code); + self.consume_confirmed_codex_account_action(); return false; } - match key.code { + if code == KeyCode::Esc + && self.current_tab == Tab::Usage + && self.should_show_codex_login_panel() + { + self.dismiss_codex_login(); + return false; + } + + match code { KeyCode::Char('q') => { self.should_quit = true; return true; @@ -539,11 +855,13 @@ impl App { self.cycle_theme(); } KeyCode::Char('r') => { - if self.background_loading { + self.last_auto_refresh = Instant::now(); + if self.current_tab == Tab::Usage { + self.refresh_usage(); + } else if self.background_loading { self.set_status("Refresh already in progress"); } else { self.needs_reload = true; - self.fetch_subscription_usage(); } } KeyCode::Char('R') if key.modifiers.contains(KeyModifiers::SHIFT) => { @@ -580,12 +898,21 @@ impl App { KeyCode::Char('g') => { self.open_group_by_picker(); } - KeyCode::Char('u') if self.current_tab == Tab::Usage => { - self.fetch_subscription_usage(); + KeyCode::Char('a') if self.current_tab == Tab::Usage => { + self.start_codex_login(); + } + KeyCode::Char('m') if self.current_tab == Tab::Usage => { + self.toggle_usage_email_privacy(); + } + KeyCode::Char('x') if self.current_tab == Tab::Usage => { + self.confirm_selected_codex_rate_limit_reset(); } KeyCode::Enter if self.current_tab == Tab::Daily => { self.open_selected_daily_detail(); } + KeyCode::Enter if self.current_tab == Tab::Monthly => { + self.open_selected_monthly_detail(); + } KeyCode::Enter if self.current_tab == Tab::Stats => { self.handle_graph_selection(); } @@ -594,39 +921,578 @@ impl App { { self.close_daily_detail(); } + KeyCode::Esc | KeyCode::Backspace + if self.current_tab == Tab::Monthly && self.is_monthly_detail_active() => + { + self.close_monthly_detail(); + } KeyCode::Esc if self.selected_graph_cell.is_some() => { self.selected_graph_cell = None; self.stats_breakdown_total_lines = 0; self.selected_index = 0; self.scroll_offset = 0; } - _ => {} + _ => {} + } + false + } + + pub fn fetch_subscription_usage(&mut self) { + self.fetch_subscription_usage_with_status(false); + } + + fn fetch_subscription_usage_preserving_status(&mut self) { + self.fetch_subscription_usage_with_status(true); + } + + fn fetch_subscription_usage_with_status(&mut self, preserve_status: bool) { + if self.usage_rx.is_some() { + if preserve_status { + self.usage_fetch_preserve_status = true; + } + return; // already fetching + } + self.usage_fetch_attempted = true; + self.usage_fetch_preserve_status = preserve_status; + self.usage_fetch_diagnostics.clear(); + if !preserve_status { + self.status_message = Some("Fetching usage data...".into()); + self.status_message_time = Some(std::time::Instant::now()); + } + let (tx, rx) = std::sync::mpsc::channel(); + self.usage_rx = Some(rx); + let fetcher = self.usage_fetcher; + std::thread::spawn(move || { + let report = fetcher(); + let _ = tx.send(report); + }); + } + + pub fn refresh_usage(&mut self) { + if self.usage_rx.is_some() { + self.set_status("Refresh already in progress"); + } else { + self.fetch_subscription_usage(); + } + } + + pub(crate) fn maybe_fetch_usage_on_entry(&mut self) { + if self.current_tab == Tab::Usage && !self.usage_fetch_attempted && self.usage_rx.is_none() + { + self.fetch_subscription_usage(); + } + } + + pub fn is_fetching_usage(&self) -> bool { + self.usage_rx.is_some() + } + + fn usage_loaded_status(&self) -> String { + match self.usage_fetch_diagnostics.len() { + 0 => "Usage data loaded".to_string(), + 1 => "Usage data loaded with 1 issue".to_string(), + count => format!("Usage data loaded with {count} issues"), + } + } + + fn usage_empty_status(&self) -> String { + if self.usage_fetch_diagnostics.is_empty() { + "No usage data available".to_string() + } else { + format!("Usage fetch failed: {}", self.usage_diagnostic_summary()) + } + } + + fn usage_diagnostic_summary(&self) -> String { + let mut names = self + .usage_fetch_diagnostics + .iter() + .map(|diagnostic| diagnostic.display_name()) + .collect::>(); + names.sort(); + names.dedup(); + let visible = names.iter().take(2).cloned().collect::>(); + let hidden = names.len().saturating_sub(visible.len()); + if hidden == 0 { + visible.join(", ") + } else { + format!("{} +{hidden}", visible.join(", ")) + } + } + + /// Cache-first load of server-side aggregated multi-device stats. + /// Called once at TUI startup; the background refresh for a stale or + /// missing cache is driven by `on_tick` via `maybe_refresh_remote_stats`. + /// Silent on every failure path — the TUI stays local-only. + pub fn init_remote_stats(&mut self) { + #[cfg(not(test))] + { + let Some(auth) = crate::auth::resolve_api_token() else { + return; + }; + // Env-provided tokens carry no username, so their responses are + // never trusted from cache (cache entries are scoped per account). + let username = auth.username.unwrap_or_default(); + let api_url = crate::auth::get_api_base_url(); + if let Some(stats) = crate::tui::remote::load_cached_remote_stats(&username, &api_url) { + self.remote_stats = Some(stats); + } + } + } + + /// Spawn a background `GET /api/me/stats` fetch when the current remote + /// stats are missing or older than the cache TTL. Attempts are throttled + /// so an offline machine or expired token does not retry on every tick. + fn maybe_refresh_remote_stats(&mut self) { + const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300); + + if self.remote_stats_rx.is_some() { + return; + } + let stale = self + .remote_stats + .as_ref() + .is_none_or(crate::tui::remote::RemoteStats::is_stale); + if !stale { + return; + } + if self + .remote_stats_last_attempt + .is_some_and(|at| at.elapsed() < RETRY_INTERVAL) + { + return; + } + self.remote_stats_last_attempt = Some(std::time::Instant::now()); + + // Tests must not read real credentials or hit the network. + #[cfg(not(test))] + { + let Some(auth) = crate::auth::resolve_api_token() else { + return; + }; + let token = auth.token; + let username = auth.username.unwrap_or_default(); + let api_url = crate::auth::get_api_base_url(); + + let (tx, rx) = std::sync::mpsc::channel(); + self.remote_stats_rx = Some(rx); + std::thread::spawn(move || { + if let Ok(stats) = + crate::tui::remote::fetch_remote_stats(&token, &username, &api_url) + { + let _ = tx.send(stats); + } + }); + } + } + + /// Poll the background remote stats fetch. Errors are silent: the sender + /// is simply dropped without a payload and the TUI stays local-only. + fn poll_remote_stats(&mut self) { + if let Some(ref rx) = self.remote_stats_rx { + match rx.try_recv() { + Ok(stats) => { + self.remote_stats_rx = None; + self.remote_stats = Some(stats); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.remote_stats_rx = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + } + } + } + + fn handle_click_action(&mut self, action: ClickAction) { + match action { + ClickAction::Tab(tab) => { + self.switch_tab(tab); + self.reset_selection(); + } + ClickAction::Sort(field) => { + self.set_sort(field); + } + ClickAction::GraphCell { week, day } => { + self.selected_graph_cell = Some((week, day)); + self.stats_breakdown_total_lines = 0; + self.selected_index = 0; + self.scroll_offset = 0; + } + ClickAction::UsageRefresh => { + self.last_auto_refresh = Instant::now(); + self.refresh_usage(); + } + ClickAction::CodexStartLogin => { + self.start_codex_login(); + } + ClickAction::CodexDismissLogin => { + self.dismiss_codex_login(); + } + ClickAction::UsageSelect { index } => { + self.selected_index = index; + self.clamp_selection(); + } + ClickAction::UsageToggleEmailPrivacy => { + self.toggle_usage_email_privacy(); + } + ClickAction::CodexUseAccount { account_id } => { + self.confirm_codex_account_switch(&account_id); + } + ClickAction::CodexRemoveAccount { account_id } => { + self.confirm_codex_account_removal(&account_id); + } + ClickAction::CodexResetAccount { account_id } => { + self.confirm_codex_rate_limit_reset(&account_id); + } + } + } + + pub fn is_codex_login_running(&self) -> bool { + self.codex_login_rx.is_some() + } + + pub fn should_show_codex_login_panel(&self) -> bool { + self.is_codex_login_running() + || self.codex_login_outcome.is_some() + || !self.codex_login_lines.is_empty() + } + + fn start_codex_login(&mut self) { + if self.codex_login_rx.is_some() { + self.set_status("Codex login already in progress"); + return; + } + + self.codex_login_lines.clear(); + self.codex_login_outcome = None; + + let (tx, rx) = std::sync::mpsc::channel(); + self.codex_login_rx = Some(rx); + let child_slot = CodexLoginChildSlot::default(); + self.codex_login_child = Some(std::sync::Arc::clone(&child_slot)); + self.set_status("Starting Codex login..."); + std::thread::spawn(move || run_codex_login_worker(tx, child_slot)); + } + + fn dismiss_codex_login(&mut self) { + if self.codex_login_rx.is_some() { + self.kill_codex_login_child(); + self.codex_login_rx = None; + self.codex_login_lines.clear(); + self.codex_login_outcome = None; + self.set_status("Codex login cancelled"); + return; + } + + self.codex_login_lines.clear(); + self.codex_login_outcome = None; + self.set_status("Codex login panel dismissed"); + } + + /// Kills any in-flight `codex login` child process. Called on dismiss and + /// on TUI exit so a dangling login can't keep holding the OAuth port. + pub fn kill_codex_login_child(&mut self) { + let Some(slot) = self.codex_login_child.take() else { + return; + }; + cancel_codex_login_child(&slot); + } + + fn confirm_codex_account_switch(&mut self, account_id: &str) { + if self.subscription_usage.iter().any(|usage| { + usage + .account + .as_ref() + .is_some_and(|account| account.id == account_id && account.is_active) + }) { + self.set_status("Codex account already active"); + return; + } + + let account_label = self.codex_account_label(account_id); + let dialog = ConfirmDialog::codex_switch( + account_id.to_string(), + account_label, + self.confirmed_codex_use_account_id.clone(), + ); + self.dialog_stack.show(Box::new(dialog)); + self.set_status("Confirm Codex account switch"); + } + + fn confirm_codex_account_removal(&mut self, account_id: &str) { + if self.subscription_usage.iter().any(|usage| { + usage + .account + .as_ref() + .is_some_and(|account| account.id == account_id && account.is_active) + }) { + self.set_status("Switch Codex accounts before removing the current account"); + return; + } + + let account_label = self.codex_account_label(account_id); + let dialog = ConfirmDialog::codex_remove( + account_id.to_string(), + account_label, + self.confirmed_codex_remove_account_id.clone(), + ); + self.dialog_stack.show(Box::new(dialog)); + self.set_status("Confirm Codex account removal"); + } + + fn consume_confirmed_codex_account_action(&mut self) { + let account_id = self.confirmed_codex_use_account_id.borrow_mut().take(); + if let Some(account_id) = account_id { + self.use_codex_account(&account_id); + return; + } + + let account_id = self.confirmed_codex_remove_account_id.borrow_mut().take(); + if let Some(account_id) = account_id { + self.remove_codex_account(&account_id); + return; + } + + let account_id = self.confirmed_codex_reset_account_id.borrow_mut().take(); + if let Some(account_id) = account_id { + self.reset_codex_rate_limits(&account_id); + } + } + + fn codex_account_label(&self, account_id: &str) -> String { + self.subscription_usage + .iter() + .find_map(|usage| { + let account = usage.account.as_ref()?; + if account.id != account_id { + return None; + } + + let label = usage + .account_display_name() + .unwrap_or_else(|| account.display_name()); + if self.hide_usage_emails && looks_like_email(&label) { + Some(format!("Account {}", account.short_id())) + } else { + Some(label) + } + }) + .unwrap_or_else(|| short_account_id(account_id)) + } + + fn use_codex_account(&mut self, account_id: &str) { + match crate::commands::usage::codex::switch_active_account(account_id) { + Ok(info) => { + self.mark_active_codex_account(&info.id); + self.sort_codex_subscription_usage(); + if let Some(index) = self.subscription_usage.iter().position(|usage| { + usage + .account + .as_ref() + .is_some_and(|account| account.id == info.id) + }) { + self.selected_index = index; + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } else if self.selected_index >= self.scroll_offset + self.max_visible_items { + self.scroll_offset = self + .selected_index + .saturating_sub(self.max_visible_items.saturating_sub(1)); + } + } + self.persist_subscription_usage_cache(); + let display = info.label.as_deref().unwrap_or(&info.id); + self.set_status(&format!("Active Codex account: {display}")); + } + Err(e) => { + self.set_status(&format!("Codex account switch failed: {e}")); + } + } + } + + fn toggle_usage_email_privacy(&mut self) { + self.hide_usage_emails = !self.hide_usage_emails; + if self.hide_usage_emails { + self.set_status("Usage emails hidden"); + } else { + self.set_status("Usage emails shown"); + } + } + + fn confirm_selected_codex_rate_limit_reset(&mut self) { + let Some(output) = self.subscription_usage.get(self.selected_index) else { + self.set_status("No usage account selected"); + return; + }; + + if output.provider != "Codex" { + self.set_status("Codex reset only supports Codex accounts"); + return; + } + + let Some(account_id) = output.account.as_ref().map(|account| account.id.clone()) else { + self.set_status("Select a saved Codex account to reset"); + return; + }; + + self.confirm_codex_rate_limit_reset(&account_id); + } + + fn confirm_codex_rate_limit_reset(&mut self, account_id: &str) { + if self.codex_reset_rx.is_some() { + self.set_status("Codex reset already in progress"); + return; + } + + let Some(output) = self.subscription_usage.iter().find(|usage| { + usage.provider == "Codex" + && usage + .account + .as_ref() + .is_some_and(|account| account.id == account_id) + }) else { + self.set_status("Codex account not found"); + return; + }; + + let available = output + .reset_credits + .as_ref() + .map(|credits| credits.available_count) + .unwrap_or(0); + if available == 0 { + self.set_status("No Codex reset credits available"); + return; + } + + let mut account_label = self.codex_account_label(account_id); + account_label.push_str(&format!(" - {available} reset")); + if available != 1 { + account_label.push('s'); + } + if let Some(expiry) = output.reset_credits.as_ref().and_then(|credits| { + credits + .credits + .iter() + .find_map(|credit| credit.expires_at.as_ref()) + }) { + account_label.push_str(&format!( + " - {}", + crate::commands::usage::helpers::format_reset_time(expiry) + .replace("resets", "expires") + )); + } + + let dialog = ConfirmDialog::codex_reset( + account_id.to_string(), + account_label, + self.confirmed_codex_reset_account_id.clone(), + ); + self.dialog_stack.show(Box::new(dialog)); + self.set_status("Confirm Codex reset credit use"); + } + + fn reset_codex_rate_limits(&mut self, account_id: &str) { + if self.codex_reset_rx.is_some() { + self.set_status("Codex reset already in progress"); + return; + } + + let account_id = account_id.to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + self.codex_reset_rx = Some(rx); + self.set_status("Resetting Codex limits..."); + std::thread::spawn(move || { + let result = + crate::commands::usage::codex::consume_rate_limit_reset_credit(&account_id) + .map_err(|error| error.to_string()); + let _ = tx.send(result); + }); + } + + fn remove_codex_account(&mut self, account_id: &str) { + match crate::commands::usage::codex::remove_account(account_id) { + Ok(info) => { + self.subscription_usage.retain(|usage| { + usage.account.as_ref().map(|account| account.id.as_str()) + != Some(info.id.as_str()) + }); + self.clamp_selection(); + if let Some(active) = crate::commands::usage::codex::list_accounts() + .into_iter() + .find(|account| account.is_active) + { + self.mark_active_codex_account(&active.id); + self.sort_codex_subscription_usage(); + } else { + self.clear_active_codex_accounts(); + } + self.persist_subscription_usage_cache(); + let display = info.label.as_deref().unwrap_or(&info.id); + self.set_status(&format!( + "Stopped tracking Codex account: {display} (codex CLI login unchanged)" + )); + } + Err(e) => { + self.set_status(&format!("Codex account removal failed: {e}")); + } } - false } - pub fn fetch_subscription_usage(&mut self) { - if self.usage_rx.is_some() { - return; // already fetching + fn persist_subscription_usage_cache(&self) { + if self.subscription_usage.is_empty() { + crate::commands::usage::clear_cache(); + } else { + crate::commands::usage::save_cache(&self.subscription_usage); } - self.usage_fetch_attempted = true; - self.status_message = Some("Fetching usage data...".into()); - self.status_message_time = Some(std::time::Instant::now()); - let (tx, rx) = std::sync::mpsc::channel(); - self.usage_rx = Some(rx); - std::thread::spawn(move || { - let results = crate::commands::usage::fetch_all(); - let _ = tx.send(results); - }); } - pub fn is_fetching_usage(&self) -> bool { - self.usage_rx.is_some() + fn mark_active_codex_account(&mut self, active_account_id: &str) { + for usage in &mut self.subscription_usage { + if usage.provider == "Codex" { + if let Some(account) = &mut usage.account { + account.is_active = account.id == active_account_id; + } + } + } + } + + fn clear_active_codex_accounts(&mut self) { + for usage in &mut self.subscription_usage { + if usage.provider == "Codex" { + if let Some(account) = &mut usage.account { + account.is_active = false; + } + } + } + } + + fn sort_codex_subscription_usage(&mut self) { + let mut codex_outputs = self + .subscription_usage + .iter() + .filter(|usage| usage.provider == "Codex") + .cloned() + .collect::>(); + if codex_outputs.len() < 2 { + return; + } + + codex_outputs.sort_by(compare_codex_usage_outputs); + let mut sorted = codex_outputs.into_iter(); + for usage in &mut self.subscription_usage { + if usage.provider == "Codex" { + if let Some(next) = sorted.next() { + *usage = next; + } + } + } } pub fn handle_mouse_event(&mut self, event: MouseEvent) { if self.dialog_stack.is_active() { self.dialog_stack.handle_mouse(event); + self.consume_confirmed_codex_account_action(); return; } @@ -635,29 +1501,19 @@ impl App { let x = event.column; let y = event.row; - for area in &self.click_areas { - if x >= area.rect.x - && x < area.rect.x + area.rect.width - && y >= area.rect.y - && y < area.rect.y + area.rect.height - { - match &area.action { - ClickAction::Tab(tab) => { - self.switch_tab(*tab); - self.reset_selection(); - } - ClickAction::Sort(field) => { - self.set_sort(*field); - } - ClickAction::GraphCell { week, day } => { - self.selected_graph_cell = Some((*week, *day)); - self.stats_breakdown_total_lines = 0; - self.selected_index = 0; - self.scroll_offset = 0; - } - } - break; - } + let action = self + .click_areas + .iter() + .find(|area| { + x >= area.rect.x + && x < area.rect.x + area.rect.width + && y >= area.rect.y + && y < area.rect.y + area.rect.height + }) + .map(|area| area.action.clone()); + + if let Some(action) = action { + self.handle_click_action(action); } } MouseEventKind::ScrollUp => { @@ -718,6 +1574,9 @@ impl App { self.selected_daily_detail_date = None; self.daily_list_selected_index = 0; self.daily_list_scroll_offset = 0; + self.selected_monthly_detail_month = None; + self.monthly_list_selected_index = 0; + self.monthly_list_scroll_offset = 0; self.selected_graph_cell = None; self.stats_breakdown_total_lines = 0; } @@ -729,6 +1588,9 @@ impl App { if target != Tab::Daily { self.selected_daily_detail_date = None; } + if target != Tab::Monthly { + self.selected_monthly_detail_month = None; + } let (field, dir) = self .tab_sort_state @@ -737,10 +1599,12 @@ impl App { .unwrap_or_else(|| Self::default_sort_for_tab(target)); self.sort_field = field; self.sort_direction = dir; + + self.maybe_fetch_usage_on_entry(); } fn default_sort_for_tab(tab: Tab) -> (SortField, SortDirection) { - if matches!(tab, Tab::Hourly | Tab::Minutely) { + if matches!(tab, Tab::Hourly | Tab::Minutely | Tab::Monthly) { (SortField::Date, SortDirection::Descending) } else { (SortField::Cost, SortDirection::Descending) @@ -896,6 +1760,10 @@ impl App { Tab::Daily => self.data.daily.len(), Tab::Hourly => self.data.hourly.len(), Tab::Minutely => self.data.minutely.len(), + Tab::Monthly if self.is_monthly_detail_active() => { + self.get_sorted_monthly_detail_days().len() + } + Tab::Monthly => self.data.monthly.len(), Tab::Stats => { if self.selected_graph_cell.is_some() { self.stats_breakdown_total_lines @@ -922,7 +1790,9 @@ impl App { self.sort_direction = SortDirection::Descending; } self.persist_current_sort(); - if self.current_tab == Tab::Daily && self.is_daily_detail_active() { + if (self.current_tab == Tab::Daily && self.is_daily_detail_active()) + || (self.current_tab == Tab::Monthly && self.is_monthly_detail_active()) + { self.selected_index = 0; self.scroll_offset = 0; } else { @@ -1077,8 +1947,61 @@ impl App { self.clamp_selection(); } + fn open_selected_monthly_detail(&mut self) { + if self.is_monthly_detail_active() { + return; + } + + let selected_month = { + let monthly = self.get_sorted_monthly(); + monthly.get(self.selected_index).map(|m| m.month.clone()) + }; + + if let Some(month) = selected_month { + self.monthly_list_selected_index = self.selected_index; + self.monthly_list_scroll_offset = self.scroll_offset; + self.selected_monthly_detail_month = Some(month.clone()); + self.selected_index = 0; + self.scroll_offset = 0; + self.set_status(&format!("Viewing daily breakdown for {}", month)); + self.clamp_selection(); + } + } + + fn close_monthly_detail(&mut self) { + let Some(ref detail_month) = self.selected_monthly_detail_month else { + return; + }; + let detail_month = detail_month.clone(); + + self.selected_monthly_detail_month = None; + + let restored_index = self + .get_sorted_monthly() + .iter() + .position(|m| m.month == detail_month) + .unwrap_or(self.monthly_list_selected_index); + + self.selected_index = restored_index; + + let max_visible = self.max_visible_items.max(1); + let viewport_still_holds = restored_index >= self.monthly_list_scroll_offset + && restored_index < self.monthly_list_scroll_offset + max_visible; + self.scroll_offset = if viewport_still_holds { + self.monthly_list_scroll_offset + } else { + restored_index.saturating_sub(max_visible / 2) + }; + + self.set_status("Returned to monthly usage"); + self.clamp_selection(); + } + fn toggle_auto_refresh(&mut self) { self.auto_refresh = !self.auto_refresh; + if self.auto_refresh { + self.last_auto_refresh = Instant::now(); + } self.settings.auto_refresh_enabled = self.auto_refresh; let save_result = self.settings.save(); let msg = if self.auto_refresh { @@ -1169,6 +2092,14 @@ impl App { m.cost ) }), + Tab::Monthly if self.is_monthly_detail_active() => self + .get_sorted_monthly_detail_days() + .get(self.selected_index) + .map(|d| format!("{}: {} tokens, ${:.4}", d.date, d.tokens.total(), d.cost)), + Tab::Monthly => self + .get_sorted_monthly() + .get(self.selected_index) + .map(|m| format!("{}: {} tokens, ${:.4}", m.month, m.tokens.total(), m.cost)), Tab::Stats | Tab::Usage => None, }; @@ -1320,6 +2251,30 @@ impl App { self.selected_daily_detail_date } + pub fn is_monthly_detail_active(&self) -> bool { + self.selected_monthly_detail_month.is_some() + } + + pub fn monthly_detail_month(&self) -> Option<&str> { + self.selected_monthly_detail_month.as_deref() + } + + pub fn get_sorted_monthly_detail_days(&self) -> Vec<&DailyUsage> { + let Some(month) = self.selected_monthly_detail_month.as_ref() else { + return Vec::new(); + }; + let Some((year, month)) = month.split_once('-').and_then(|(year, month)| { + Some((year.parse::().ok()?, month.parse::().ok()?)) + }) else { + return Vec::new(); + }; + + self.get_sorted_daily() + .into_iter() + .filter(|day| day.date.year() == year && day.date.month() == month) + .collect() + } + pub fn get_sorted_daily_detail_rows(&self) -> Vec> { let Some(date) = self.selected_daily_detail_date else { return Vec::new(); @@ -1493,6 +2448,43 @@ impl App { .collect() } + pub fn get_sorted_monthly(&self) -> Vec<&MonthlyUsage> { + let mut monthly: Vec<&MonthlyUsage> = self.data.monthly.iter().collect(); + + match (self.sort_field, self.sort_direction) { + (SortField::Cost, SortDirection::Descending) => monthly.sort_by(|a, b| { + b.cost + .total_cmp(&a.cost) + .then_with(|| b.month.cmp(&a.month)) + }), + (SortField::Cost, SortDirection::Ascending) => monthly.sort_by(|a, b| { + a.cost + .total_cmp(&b.cost) + .then_with(|| a.month.cmp(&b.month)) + }), + (SortField::Tokens, SortDirection::Descending) => monthly.sort_by(|a, b| { + b.tokens + .total() + .cmp(&a.tokens.total()) + .then_with(|| b.month.cmp(&a.month)) + }), + (SortField::Tokens, SortDirection::Ascending) => monthly.sort_by(|a, b| { + a.tokens + .total() + .cmp(&b.tokens.total()) + .then_with(|| a.month.cmp(&b.month)) + }), + (SortField::Date, SortDirection::Descending) => { + monthly.sort_by(|a, b| b.month.cmp(&a.month)) + } + (SortField::Date, SortDirection::Ascending) => { + monthly.sort_by(|a, b| a.month.cmp(&b.month)) + } + } + + monthly + } + pub fn is_narrow(&self) -> bool { self.terminal_width < 80 } @@ -1506,22 +2498,27 @@ impl App { mod tests { use super::super::ui::widgets::get_provider_shade; use super::*; + use crate::commands::usage::{ + UsageAccount, UsageFetchDiagnostic, UsageFetchReport, UsageMetric, UsageOutput, + }; use crate::tui::data::{DailyModelInfo, DailySourceInfo, ModelUsage, TokenBreakdown}; use chrono::{NaiveDate, NaiveDateTime}; use std::collections::{BTreeMap, BTreeSet}; + use std::{env, fs}; #[test] fn test_tab_all() { let tabs = Tab::all(); - assert_eq!(tabs.len(), 8); + assert_eq!(tabs.len(), 9); assert_eq!(tabs[0], Tab::Overview); assert_eq!(tabs[1], Tab::Usage); assert_eq!(tabs[2], Tab::Models); assert_eq!(tabs[3], Tab::Daily); assert_eq!(tabs[4], Tab::Hourly); assert_eq!(tabs[5], Tab::Minutely); - assert_eq!(tabs[6], Tab::Stats); - assert_eq!(tabs[7], Tab::Agents); + assert_eq!(tabs[6], Tab::Monthly); + assert_eq!(tabs[7], Tab::Stats); + assert_eq!(tabs[8], Tab::Agents); } #[test] @@ -1531,7 +2528,8 @@ mod tests { assert_eq!(Tab::Models.next(), Tab::Daily); assert_eq!(Tab::Daily.next(), Tab::Hourly); assert_eq!(Tab::Hourly.next(), Tab::Minutely); - assert_eq!(Tab::Minutely.next(), Tab::Stats); + assert_eq!(Tab::Minutely.next(), Tab::Monthly); + assert_eq!(Tab::Monthly.next(), Tab::Stats); assert_eq!(Tab::Stats.next(), Tab::Agents); assert_eq!(Tab::Agents.next(), Tab::Overview); } @@ -1544,7 +2542,8 @@ mod tests { assert_eq!(Tab::Daily.prev(), Tab::Models); assert_eq!(Tab::Hourly.prev(), Tab::Daily); assert_eq!(Tab::Minutely.prev(), Tab::Hourly); - assert_eq!(Tab::Stats.prev(), Tab::Minutely); + assert_eq!(Tab::Monthly.prev(), Tab::Minutely); + assert_eq!(Tab::Stats.prev(), Tab::Monthly); assert_eq!(Tab::Agents.prev(), Tab::Stats); } @@ -1556,6 +2555,7 @@ mod tests { assert_eq!(Tab::Daily.as_str(), "Daily"); assert_eq!(Tab::Hourly.as_str(), "Hourly"); assert_eq!(Tab::Minutely.as_str(), "Minutely"); + assert_eq!(Tab::Monthly.as_str(), "Monthly"); assert_eq!(Tab::Stats.as_str(), "Stats"); } @@ -1567,6 +2567,7 @@ mod tests { assert_eq!(Tab::Daily.short_name(), "Day"); assert_eq!(Tab::Hourly.short_name(), "Hr"); assert_eq!(Tab::Minutely.short_name(), "Min"); + assert_eq!(Tab::Monthly.short_name(), "Mon"); assert_eq!(Tab::Stats.short_name(), "Sta"); } @@ -1784,6 +2785,76 @@ mod tests { assert!(!app.should_quit); } + #[test] + #[serial_test::serial] + fn app_uses_saved_theme_when_cli_theme_is_absent() { + let temp = tempfile::TempDir::new().unwrap(); + let previous_config_dir = env::var_os("TOKSCALE_CONFIG_DIR"); + unsafe { + env::set_var("TOKSCALE_CONFIG_DIR", temp.path()); + } + fs::write( + temp.path().join("settings.json"), + r#"{"colorPalette":"halloween"}"#, + ) + .unwrap(); + + let config = TuiConfig { + theme: String::new(), + refresh: 0, + sessions_path: None, + clients: None, + since: None, + until: None, + year: None, + initial_tab: None, + }; + let app = App::new_with_cached_data(config, None).unwrap(); + + unsafe { + match previous_config_dir { + Some(value) => env::set_var("TOKSCALE_CONFIG_DIR", value), + None => env::remove_var("TOKSCALE_CONFIG_DIR"), + } + } + assert_eq!(app.theme.name, ThemeName::Halloween); + } + + #[test] + #[serial_test::serial] + fn app_explicit_cli_theme_overrides_saved_theme() { + let temp = tempfile::TempDir::new().unwrap(); + let previous_config_dir = env::var_os("TOKSCALE_CONFIG_DIR"); + unsafe { + env::set_var("TOKSCALE_CONFIG_DIR", temp.path()); + } + fs::write( + temp.path().join("settings.json"), + r#"{"colorPalette":"halloween"}"#, + ) + .unwrap(); + + let config = TuiConfig { + theme: "blue".to_string(), + refresh: 0, + sessions_path: None, + clients: None, + since: None, + until: None, + year: None, + initial_tab: None, + }; + let app = App::new_with_cached_data(config, None).unwrap(); + + unsafe { + match previous_config_dir { + Some(value) => env::set_var("TOKSCALE_CONFIG_DIR", value), + None => env::remove_var("TOKSCALE_CONFIG_DIR"), + } + } + assert_eq!(app.theme.name, ThemeName::Blue); + } + // ── Helper ────────────────────────────────────────────────────── fn make_app() -> App { @@ -1800,6 +2871,121 @@ mod tests { App::new_with_cached_data(config, None).unwrap() } + fn usage_output(provider: &str, account: Option) -> UsageOutput { + UsageOutput { + provider: provider.to_string(), + account, + plan: Some("Pro".to_string()), + email: None, + metrics: vec![UsageMetric { + label: "Session".to_string(), + used_percent: 20.0, + remaining_percent: 80.0, + remaining_label: Some("80% left".to_string()), + resets_at: None, + }], + reset_credits: None, + credit_status: None, + spend_control: None, + } + } + + fn sample_subscription_usage() -> Vec { + vec![usage_output( + "Codex", + Some(UsageAccount { + id: "acct_work".to_string(), + label: Some("work".to_string()), + is_active: true, + }), + )] + } + + fn sample_usage_fetcher() -> UsageFetchReport { + UsageFetchReport { + outputs: sample_subscription_usage(), + diagnostics: Vec::new(), + } + } + + fn failing_usage_fetcher() -> UsageFetchReport { + UsageFetchReport { + outputs: Vec::new(), + diagnostics: vec![UsageFetchDiagnostic::new( + "Codex", + None, + "token refresh failed", + )], + } + } + + fn partial_usage_fetcher() -> UsageFetchReport { + UsageFetchReport { + outputs: sample_subscription_usage(), + diagnostics: vec![UsageFetchDiagnostic::new( + "Codex", + Some(UsageAccount { + id: "acct_personal".to_string(), + label: Some("personal".to_string()), + is_active: false, + }), + "usage endpoint rejected credentials", + )], + } + } + + fn drain_usage_fetch(app: &mut App) { + for _ in 0..20 { + app.on_tick(); + if !app.is_fetching_usage() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn test_codex_usage_sort_moves_active_account_to_first_codex_row() { + let mut app = make_app(); + app.subscription_usage = vec![ + usage_output("Claude", None), + usage_output( + "Codex", + Some(UsageAccount { + id: "acct_work".to_string(), + label: Some("work".to_string()), + is_active: true, + }), + ), + usage_output("Warp/Oz", None), + usage_output( + "Codex", + Some(UsageAccount { + id: "acct_personal".to_string(), + label: Some("personal".to_string()), + is_active: false, + }), + ), + ]; + + app.mark_active_codex_account("acct_personal"); + app.sort_codex_subscription_usage(); + + assert_eq!(app.subscription_usage[0].provider, "Claude"); + assert_eq!(app.subscription_usage[2].provider, "Warp/Oz"); + let codex_ids = app + .subscription_usage + .iter() + .filter(|usage| usage.provider == "Codex") + .filter_map(|usage| usage.account.as_ref().map(|account| account.id.as_str())) + .collect::>(); + assert_eq!(codex_ids, vec!["acct_personal", "acct_work"]); + assert!(app.subscription_usage[1] + .account + .as_ref() + .is_some_and(|account| account.is_active)); + } + #[test] fn test_app_no_filter_default_matches_default_set() { // Regression for an Oracle-flagged HIGH bug: the no-filter TUI @@ -2006,6 +3192,193 @@ mod tests { ); } + fn monthly_usage(month: &str, input_tokens: u64, cost: f64) -> MonthlyUsage { + MonthlyUsage { + month: month.to_string(), + tokens: TokenBreakdown { + input: input_tokens, + output: 0, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }, + cost, + message_count: 1, + turn_count: 1, + } + } + + #[test] + fn test_get_sorted_monthly_defaults_to_date_descending() { + let mut app = make_app(); + app.switch_tab(Tab::Monthly); + app.data.monthly = vec![ + monthly_usage("2026-03", 100, 1.0), + monthly_usage("2026-05", 200, 2.0), + monthly_usage("2026-04", 300, 3.0), + ]; + + let sorted = app + .get_sorted_monthly() + .iter() + .map(|m| m.month.as_str()) + .collect::>(); + assert_eq!(sorted, vec!["2026-05", "2026-04", "2026-03"]); + } + + #[test] + fn test_get_sorted_monthly_by_cost() { + let mut app = make_app(); + app.current_tab = Tab::Monthly; + app.sort_field = SortField::Cost; + app.sort_direction = SortDirection::Ascending; + app.data.monthly = vec![ + monthly_usage("2026-05", 100, 3.0), + monthly_usage("2026-04", 100, 1.0), + monthly_usage("2026-06", 100, 2.0), + ]; + + let sorted = app + .get_sorted_monthly() + .iter() + .map(|m| m.month.as_str()) + .collect::>(); + assert_eq!(sorted, vec!["2026-04", "2026-06", "2026-05"]); + } + + #[test] + fn test_get_sorted_monthly_detail_days_filters_by_month() { + let mut app = make_app(); + app.current_tab = Tab::Monthly; + app.selected_monthly_detail_month = Some("2026-05".to_string()); + app.data.daily = vec![ + daily_usage("2026-05-10", 1.0, vec![("model-a", "openai", 1.0)]), + daily_usage("2026-05-20", 2.0, vec![("model-b", "anthropic", 2.0)]), + daily_usage("2026-06-01", 3.0, vec![("model-c", "google", 3.0)]), + ]; + + let days = app.get_sorted_monthly_detail_days(); + assert_eq!(days.len(), 2); + assert_eq!(days[0].date.to_string(), "2026-05-20"); + assert_eq!(days[1].date.to_string(), "2026-05-10"); + } + + #[test] + fn test_open_monthly_detail_shows_daily_breakdown() { + let mut app = make_app(); + app.switch_tab(Tab::Monthly); + app.data.monthly = vec![ + monthly_usage("2026-05", 100, 1.0), + monthly_usage("2026-04", 200, 2.0), + ]; + app.data.daily = vec![ + daily_usage("2026-05-10", 1.0, vec![("model-a", "openai", 1.0)]), + daily_usage("2026-04-05", 2.0, vec![("model-b", "anthropic", 2.0)]), + ]; + + app.handle_key_event(key(KeyCode::Enter)); + + assert!(app.is_monthly_detail_active()); + assert_eq!(app.monthly_detail_month(), Some("2026-05")); + assert_eq!(app.get_sorted_monthly_detail_days().len(), 1); + assert_eq!(app.selected_index, 0); + } + + #[test] + fn test_esc_closes_monthly_detail_and_restores_selection() { + let mut app = make_app(); + app.switch_tab(Tab::Monthly); + app.data.monthly = vec![ + monthly_usage("2026-05", 100, 1.0), + monthly_usage("2026-04", 200, 2.0), + ]; + app.data.daily = vec![ + daily_usage("2026-05-10", 1.0, vec![("model-a", "openai", 1.0)]), + daily_usage("2026-04-05", 2.0, vec![("model-b", "anthropic", 2.0)]), + ]; + + app.handle_key_event(key(KeyCode::Enter)); + assert!(app.is_monthly_detail_active()); + + app.handle_key_event(key(KeyCode::Esc)); + + assert!(!app.is_monthly_detail_active()); + assert_eq!(app.monthly_detail_month(), None); + assert_eq!(app.current_tab, Tab::Monthly); + } + + #[test] + fn test_close_monthly_detail_restores_saved_viewport() { + let mut app = make_app(); + app.switch_tab(Tab::Monthly); + app.data.monthly = (1..=10) + .map(|month| monthly_usage(&format!("2026-{month:02}"), 100, 1.0)) + .collect(); + app.data.daily = vec![daily_usage( + "2026-03-10", + 1.0, + vec![("model-a", "openai", 1.0)], + )]; + app.max_visible_items = 4; + app.selected_index = 7; + app.scroll_offset = 6; + + app.open_selected_monthly_detail(); + assert_eq!(app.monthly_detail_month(), Some("2026-03")); + assert_eq!(app.scroll_offset, 0); + + app.close_monthly_detail(); + + assert_eq!(app.selected_index, 7); + assert_eq!(app.scroll_offset, 6); + } + + #[test] + fn test_switch_tab_clears_monthly_detail() { + let mut app = make_app(); + app.switch_tab(Tab::Monthly); + app.data.monthly = vec![monthly_usage("2026-05", 100, 1.0)]; + app.data.daily = vec![daily_usage( + "2026-05-10", + 1.0, + vec![("model-a", "openai", 1.0)], + )]; + + app.open_selected_monthly_detail(); + assert!(app.is_monthly_detail_active()); + + app.switch_tab(Tab::Daily); + + assert!(!app.is_monthly_detail_active()); + } + + #[test] + fn test_update_data_exits_monthly_detail_when_month_disappears() { + let mut app = make_app(); + app.switch_tab(Tab::Monthly); + app.data.monthly = vec![monthly_usage("2026-05", 100, 1.0)]; + app.data.daily = vec![daily_usage( + "2026-05-10", + 1.0, + vec![("model-a", "openai", 1.0)], + )]; + + app.open_selected_monthly_detail(); + assert!(app.is_monthly_detail_active()); + + app.update_data(UsageData { + monthly: vec![monthly_usage("2026-04", 200, 2.0)], + daily: vec![daily_usage( + "2026-04-05", + 2.0, + vec![("model-b", "anthropic", 2.0)], + )], + ..Default::default() + }); + + assert!(!app.is_monthly_detail_active()); + } + fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } @@ -2032,6 +3405,24 @@ mod tests { assert!(app.should_quit); } + #[test] + fn test_handle_key_quit_q_russian_layout() { + // Physical `Q` on a Russian layout produces 'й'; it must still quit. + let mut app = make_app(); + let quit = app.handle_key_event(key(KeyCode::Char('й'))); + assert!(quit); + assert!(app.should_quit); + } + + #[test] + fn test_handle_key_quit_ctrl_c_russian_layout() { + // Physical `C` on a Russian layout produces 'с'; Ctrl+С must still quit. + let mut app = make_app(); + let quit = app.handle_key_event(key_with_mod(KeyCode::Char('с'), KeyModifiers::CONTROL)); + assert!(quit); + assert!(app.should_quit); + } + // ── handle_key_event: tab switching ───────────────────────────── #[test] @@ -2051,6 +3442,9 @@ mod tests { app.handle_key_event(key(KeyCode::Tab)); assert_eq!(app.current_tab, Tab::Hourly); + app.handle_key_event(key(KeyCode::Tab)); + assert_eq!(app.current_tab, Tab::Monthly); + app.handle_key_event(key(KeyCode::Tab)); assert_eq!(app.current_tab, Tab::Stats); @@ -2072,6 +3466,9 @@ mod tests { app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Stats); + app.handle_key_event(key(KeyCode::BackTab)); + assert_eq!(app.current_tab, Tab::Monthly); + app.handle_key_event(key(KeyCode::BackTab)); assert_eq!(app.current_tab, Tab::Hourly); @@ -2100,6 +3497,7 @@ mod tests { Tab::Daily, Tab::Hourly, Tab::Minutely, + Tab::Monthly, Tab::Stats, Tab::Agents, Tab::Overview, @@ -2623,7 +4021,7 @@ mod tests { app.handle_key_event(key(KeyCode::Char('p'))); assert_ne!(app.theme.name, initial_theme); - for _ in 0..8 { + for _ in 1..ThemeName::all().len() { app.handle_key_event(key(KeyCode::Char('p'))); } assert_eq!(app.theme.name, initial_theme); @@ -2663,12 +4061,198 @@ mod tests { app.handle_key_event(key(KeyCode::Char('r'))); assert!(!app.needs_reload); + assert!(!app.is_fetching_usage()); assert_eq!( app.status_message.as_deref(), Some("Refresh already in progress") ); } + #[test] + fn test_handle_key_refresh_usage_tab_fetches_usage() { + let mut app = make_app(); + app.usage_fetcher = sample_usage_fetcher; + app.current_tab = Tab::Usage; + + app.handle_key_event(key(KeyCode::Char('r'))); + + assert!(!app.needs_reload); + assert!(app.is_fetching_usage()); + assert_eq!( + app.status_message.as_deref(), + Some("Fetching usage data...") + ); + + drain_usage_fetch(&mut app); + + assert_eq!(app.subscription_usage.len(), 1); + assert_eq!(app.subscription_usage[0].provider, "Codex"); + assert_eq!(app.status_message.as_deref(), Some("Usage data loaded")); + } + + #[test] + fn test_handle_key_refresh_usage_tab_reports_fetch_failure_diagnostic() { + let mut app = make_app(); + app.usage_fetcher = failing_usage_fetcher; + app.current_tab = Tab::Usage; + + app.handle_key_event(key(KeyCode::Char('r'))); + drain_usage_fetch(&mut app); + + assert!(app.subscription_usage.is_empty()); + assert_eq!(app.usage_fetch_diagnostics.len(), 1); + assert_eq!( + app.status_message.as_deref(), + Some("Usage fetch failed: Codex") + ); + } + + #[test] + fn test_handle_key_refresh_usage_tab_keeps_partial_fetch_diagnostic() { + let mut app = make_app(); + app.usage_fetcher = partial_usage_fetcher; + app.current_tab = Tab::Usage; + + app.handle_key_event(key(KeyCode::Char('r'))); + drain_usage_fetch(&mut app); + + assert_eq!(app.subscription_usage.len(), 1); + assert_eq!(app.usage_fetch_diagnostics.len(), 1); + assert_eq!( + app.status_message.as_deref(), + Some("Usage data loaded with 1 issue") + ); + } + + #[test] + fn test_handle_key_refresh_usage_tab_clears_stale_diagnostics() { + let mut app = make_app(); + app.current_tab = Tab::Usage; + app.usage_fetch_diagnostics = vec![UsageFetchDiagnostic::new("Codex", None, "stale issue")]; + + app.handle_key_event(key(KeyCode::Char('r'))); + + assert!(app.is_fetching_usage()); + assert!(app.usage_fetch_diagnostics.is_empty()); + } + + #[test] + fn test_handle_key_u_on_usage_is_unassigned() { + let mut app = make_app(); + app.current_tab = Tab::Usage; + + app.handle_key_event(key(KeyCode::Char('u'))); + + assert!(!app.needs_reload); + assert!(!app.is_fetching_usage()); + assert!(!app.usage_fetch_attempted); + } + + #[test] + fn test_auto_refresh_on_usage_refreshes_usage_only() { + let mut app = make_app(); + app.current_tab = Tab::Usage; + app.auto_refresh = true; + app.auto_refresh_interval = Duration::from_millis(1); + app.last_auto_refresh = Instant::now() - Duration::from_secs(1); + + app.on_tick(); + + assert!(!app.needs_reload); + assert!(app.usage_fetch_attempted); + } + + #[test] + fn test_auto_refresh_on_usage_while_fetching_preserves_status() { + let mut app = make_app(); + app.current_tab = Tab::Usage; + app.auto_refresh = true; + app.auto_refresh_interval = Duration::from_millis(1); + app.last_auto_refresh = Instant::now() - Duration::from_secs(1); + let (_tx, rx) = std::sync::mpsc::channel(); + app.usage_rx = Some(rx); + app.status_message = Some("Existing status".into()); + + app.on_tick(); + + assert_eq!(app.status_message.as_deref(), Some("Existing status")); + assert!(!app.needs_reload); + } + + #[test] + fn test_auto_refresh_on_usage_when_idle_preserves_status() { + // The while-fetching case above hits the early return in + // fetch_subscription_usage_with_status. This covers the idle case + // (no fetch in flight), where a non-preserving fetch would overwrite + // the status with "Fetching usage data...". Auto-refresh must keep the + // existing message and start a silent background fetch. + let mut app = make_app(); + app.current_tab = Tab::Usage; + app.auto_refresh = true; + app.auto_refresh_interval = Duration::from_millis(1); + app.last_auto_refresh = Instant::now() - Duration::from_secs(1); + app.status_message = Some("Existing status".into()); + assert!(app.usage_rx.is_none()); + + app.on_tick(); + + assert_eq!(app.status_message.as_deref(), Some("Existing status")); + assert!(app.usage_fetch_attempted); + assert!(!app.needs_reload); + } + + #[test] + fn test_auto_refresh_on_overview_refreshes_token_data_only() { + let mut app = make_app(); + app.current_tab = Tab::Overview; + app.auto_refresh = true; + app.auto_refresh_interval = Duration::from_millis(1); + app.last_auto_refresh = Instant::now() - Duration::from_secs(1); + + app.on_tick(); + + assert!(app.needs_reload); + assert!(!app.usage_fetch_attempted); + assert!(!app.is_fetching_usage()); + } + + #[test] + fn test_codex_reset_success_status_survives_follow_up_usage_refresh() { + let mut app = make_app(); + let (tx, rx) = std::sync::mpsc::channel(); + app.codex_reset_rx = Some(rx); + tx.send(Ok( + crate::commands::usage::codex::RateLimitResetConsumeResult { + code: "reset".to_string(), + windows_reset: Some(1), + }, + )) + .unwrap(); + drop(tx); + + app.on_tick(); + + assert_eq!( + app.status_message.as_deref(), + Some("Codex reset credit: reset 1 window") + ); + assert!(app.is_fetching_usage()); + + for _ in 0..20 { + app.on_tick(); + if !app.is_fetching_usage() { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + + assert!(!app.is_fetching_usage()); + assert_eq!( + app.status_message.as_deref(), + Some("Codex reset credit: reset 1 window") + ); + } + // ── handle_key_event: misc keys ───────────────────────────────── #[test] @@ -2706,6 +4290,21 @@ mod tests { assert_ne!(app.auto_refresh, initial); } + #[test] + fn test_enabling_auto_refresh_waits_for_next_interval() { + let mut app = make_app(); + app.auto_refresh = false; + app.auto_refresh_interval = Duration::from_secs(60); + app.last_auto_refresh = Instant::now() - Duration::from_secs(120); + + app.handle_key_event(key_with_mod(KeyCode::Char('R'), KeyModifiers::SHIFT)); + app.on_tick(); + + assert!(app.auto_refresh); + assert!(!app.needs_reload); + assert!(!app.usage_fetch_attempted); + } + #[test] fn test_handle_key_increase_decrease_refresh() { let mut app = make_app(); @@ -2769,6 +4368,79 @@ mod tests { assert_eq!(app.selected_graph_cell, Some((2, 3))); } + #[test] + fn test_handle_mouse_click_usage_refresh_uses_refresh_path() { + let mut app = make_app(); + app.background_loading = true; + app.add_click_area(Rect::new(0, 0, 10, 2), ClickAction::UsageRefresh); + + let event = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 1, + modifiers: KeyModifiers::NONE, + }; + app.handle_mouse_event(event); + + assert!(!app.needs_reload); + assert!(app.is_fetching_usage()); + assert_eq!( + app.status_message.as_deref(), + Some("Fetching usage data...") + ); + } + + #[test] + fn test_handle_mouse_click_codex_remove_opens_confirmation_dialog() { + let mut app = make_app(); + app.add_click_area( + Rect::new(0, 0, 10, 2), + ClickAction::CodexRemoveAccount { + account_id: "acct_work".to_string(), + }, + ); + + let event = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 1, + modifiers: KeyModifiers::NONE, + }; + app.handle_mouse_event(event); + + assert!(app.dialog_stack.is_active()); + assert_eq!( + app.status_message.as_deref(), + Some("Confirm Codex account removal") + ); + } + + #[test] + fn test_handle_mouse_click_codex_remove_refuses_active_account() { + let mut app = make_app(); + app.subscription_usage = sample_subscription_usage(); + app.add_click_area( + Rect::new(0, 0, 10, 2), + ClickAction::CodexRemoveAccount { + account_id: "acct_work".to_string(), + }, + ); + + let event = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 5, + row: 1, + modifiers: KeyModifiers::NONE, + }; + app.handle_mouse_event(event); + + assert!(!app.dialog_stack.is_active()); + assert_eq!( + app.status_message.as_deref(), + Some("Switch Codex accounts before removing the current account") + ); + } + #[test] fn test_handle_mouse_click_outside_areas() { let mut app = make_app(); @@ -2911,6 +4583,109 @@ mod tests { assert_eq!(app.status_message.as_ref().unwrap(), "fresh message"); } + #[test] + fn test_on_tick_collects_codex_login_events() { + let mut app = make_app(); + let (tx, rx) = std::sync::mpsc::channel(); + app.codex_login_rx = Some(rx); + tx.send(CodexLoginEvent::Output( + "Open https://example.com/device".to_string(), + )) + .unwrap(); + tx.send(CodexLoginEvent::Finished(CodexLoginOutcome::Failed( + "expired".to_string(), + ))) + .unwrap(); + drop(tx); + + app.on_tick(); + + assert!(app.codex_login_rx.is_none()); + assert_eq!( + app.codex_login_lines.last().map(String::as_str), + Some("Open https://example.com/device") + ); + assert!(matches!( + app.codex_login_outcome, + Some(CodexLoginOutcome::Failed(ref error)) if error == "expired" + )); + assert_eq!( + app.status_message.as_deref(), + Some("Codex login failed: expired") + ); + } + + #[test] + fn test_on_tick_clears_codex_login_panel_after_import() { + let mut app = make_app(); + app.background_loading = true; + let (tx, rx) = std::sync::mpsc::channel(); + app.codex_login_rx = Some(rx); + tx.send(CodexLoginEvent::Output( + "Starting Codex browser login".to_string(), + )) + .unwrap(); + tx.send(CodexLoginEvent::Finished(CodexLoginOutcome::Imported( + crate::commands::usage::codex::CodexAccountInfo { + id: "acct_work".to_string(), + label: Some("work".to_string()), + account_id: Some("acct_work".to_string()), + created_at: "2026-06-09T00:00:00Z".to_string(), + is_active: true, + }, + ))) + .unwrap(); + drop(tx); + + app.on_tick(); + + assert!(app.codex_login_rx.is_none()); + assert!(app.codex_login_lines.is_empty()); + assert!(app.codex_login_outcome.is_none()); + assert!(!app.should_show_codex_login_panel()); + } + + #[cfg(unix)] + #[test] + fn test_dismiss_codex_login_while_running_kills_child() { + let mut app = make_app(); + let child = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + let (_tx, rx) = std::sync::mpsc::channel(); + app.codex_login_rx = Some(rx); + app.codex_login_lines.push("waiting".to_string()); + let slot = CodexLoginChildSlot::default(); + crate::tui::codex_login::put_codex_login_child_for_test(&slot, child).unwrap(); + app.codex_login_child = Some(std::sync::Arc::clone(&slot)); + + app.dismiss_codex_login(); + + assert!(app.codex_login_rx.is_none()); + assert!(app.codex_login_child.is_none()); + assert!(app.codex_login_lines.is_empty()); + assert!(app.codex_login_outcome.is_none()); + assert!(crate::tui::codex_login::codex_login_slot_child_is_none_for_test(&slot)); + assert_eq!(app.status_message.as_deref(), Some("Codex login cancelled")); + } + + #[test] + fn test_dismiss_codex_login_when_idle_clears_panel() { + let mut app = make_app(); + app.codex_login_lines.push("stale".to_string()); + app.codex_login_outcome = Some(CodexLoginOutcome::Failed("expired".to_string())); + + app.dismiss_codex_login(); + + assert!(app.codex_login_lines.is_empty()); + assert!(app.codex_login_outcome.is_none()); + assert_eq!( + app.status_message.as_deref(), + Some("Codex login panel dismissed") + ); + } + // ── click area management ─────────────────────────────────────── #[test] @@ -3005,7 +4780,7 @@ mod tests { } #[test] - fn test_shade_map_assigns_rank_0_to_highest_cost() { + fn test_shade_map_ranks_by_family_hierarchy() { let mut app = make_app(); app.data.models = vec![ model_usage("claude-haiku-4-5", 10.0, None), @@ -3242,4 +5017,51 @@ mod tests { app.model_color_for("openai", "sonnet-shared") ); } + + #[test] + fn test_gateway_provider_model_uses_model_vendor_color() { + // Regression: a Claude model served through the github-copilot gateway + // must render in the Anthropic ramp, not the neutral "unknown" gray. + // The gateway has no vendor palette of its own, so the model's own + // vendor decides the color. + let mut app = make_app(); + let copilot_fable = ModelUsage { + model: "claude-fable-5".to_string(), + provider: "github-copilot".to_string(), + client: "opencode".to_string(), + workspace_key: None, + workspace_label: None, + tokens: TokenBreakdown::default(), + cost: 3.0, + performance: Default::default(), + session_count: 1, + }; + app.data.models = vec![ + ModelUsage { + provider: "anthropic".to_string(), + cost: 5.0, + ..copilot_fable.clone() + }, + copilot_fable, + ]; + app.build_model_shade_map(); + + // Fable is the flagship tier -> base Anthropic shade. + let anthropic_base = app.theme.color(get_provider_shade("anthropic", 0)); + assert_eq!( + app.model_color_for("github-copilot", "claude-fable-5"), + anthropic_base, + "copilot-served Claude must use the Anthropic ramp, not unknown gray" + ); + // Identical to the natively-served model. + assert_eq!( + app.model_color_for("anthropic", "claude-fable-5"), + app.model_color_for("github-copilot", "claude-fable-5") + ); + // And definitively not the neutral gray a raw github-copilot key yields. + assert_ne!( + app.model_color_for("github-copilot", "claude-fable-5"), + app.theme.color(get_provider_shade("github-copilot", 0)) + ); + } } diff --git a/crates/tokscale-cli/src/tui/cache.rs b/crates/tokscale-cli/src/tui/cache.rs index 18bd037ce..9f7594f10 100644 --- a/crates/tokscale-cli/src/tui/cache.rs +++ b/crates/tokscale-cli/src/tui/cache.rs @@ -15,13 +15,13 @@ use tokscale_core::{sessions, GroupBy, ModelPerformance}; use crate::ClientFilter; use super::data::{ - AgentUsage, ContributionDay, DailyModelInfo, DailySourceInfo, DailyUsage, GraphData, - HourlyModelInfo, HourlyUsage, ModelUsage, TokenBreakdown, UsageData, + aggregate_monthly_from_daily, AgentUsage, ContributionDay, DailyModelInfo, DailySourceInfo, + DailyUsage, GraphData, HourlyModelInfo, HourlyUsage, ModelUsage, TokenBreakdown, UsageData, }; /// Cache staleness threshold: 5 minutes (matches TS implementation) const CACHE_STALE_THRESHOLD_MS: u64 = 5 * 60 * 1000; -const CACHE_SCHEMA_VERSION: u32 = 9; +const CACHE_SCHEMA_VERSION: u32 = 10; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -625,16 +625,19 @@ impl TryFrom for UsageData { let hourly: Result, _> = u.hourly.into_iter().map(|h| h.try_into()).collect(); let graph: Option> = u.graph.map(|g| g.try_into()); + let daily = daily?; + let monthly = aggregate_monthly_from_daily(&daily); Ok(Self { models: u.models.into_iter().map(|m| m.into()).collect(), agents: normalize_cached_agents(u.agents), - daily: daily?, + daily, hourly: hourly?, // Minutely data is recomputed on each load (high cardinality, // not worth round-tripping through the on-disk cache); the // first foreground refresh after cache hit will populate it. minutely: Vec::new(), + monthly, graph: graph.transpose()?, total_tokens: u.total_tokens, total_cost: u.total_cost, @@ -691,8 +694,14 @@ fn normalize_cached_agents(agents: Vec) -> Vec { } fn normalize_cached_agent_name(agent: &str, clients: &str) -> String { - if clients.split(", ").any(|client| client == "opencode") { + // Mirror the per-client normalization in `tui::data` (see the `msg.agent` + // branch there): copilot and opencode agent ids use bespoke normalizers, + // everything else falls back to the generic one. Keep these two in sync. + let has_client = |name: &str| clients.split(", ").any(|client| client == name); + if has_client("opencode") { sessions::normalize_opencode_agent_name(agent) + } else if has_client("copilot") { + sessions::normalize_copilot_agent_name(agent) } else { sessions::normalize_agent_name(agent) } @@ -999,6 +1008,28 @@ mod tests { assert_eq!(prometheus.message_count, 1); } + #[test] + fn test_normalize_cached_agents_merges_copilot_display_variants() { + // Copilot cached agent ids must go through normalize_copilot_agent_name + // (mirroring tui::data). Without the copilot branch in + // normalize_cached_agent_name, the raw "github.copilot.default" id would + // be left untouched (generic normalizer titlecases it differently) and + // would NOT merge with the "GitHub Copilot" display name. + let agents = normalize_cached_agents(vec![ + cached_agent("github.copilot.default", "copilot", 10), + cached_agent("GITHUB.COPILOT.DEFAULT", "copilot", 20), + ]); + + assert_eq!(agents.len(), 1); + let copilot = agents + .iter() + .find(|agent| agent.agent == "GitHub Copilot") + .unwrap(); + assert_eq!(copilot.clients, "copilot"); + assert_eq!(copilot.message_count, 2); + assert_eq!(copilot.tokens.input, 30); + } + // ── check_client_match ────────────────────────────────────────── #[test] @@ -1117,7 +1148,7 @@ mod tests { fs::write( &cache_path, r#"{ - "schemaVersion": 9, + "schemaVersion": 10, "timestamp": 9999999999999, "enabledClients": ["claude"], "includeSynthetic": false, @@ -1440,7 +1471,7 @@ mod tests { fs::write( &cache_path, r#"{ - "schemaVersion": 9, + "schemaVersion": 10, "timestamp": 9999999999999, "enabledClients": ["claude", "cursor"], "includeSynthetic": false, @@ -1722,7 +1753,7 @@ mod tests { fs::write( &legacy_path, r#"{ - "schemaVersion": 9, + "schemaVersion": 10, "timestamp": 9999999999999, "enabledClients": ["claude"], "includeSynthetic": false, diff --git a/crates/tokscale-cli/src/tui/client_ui.rs b/crates/tokscale-cli/src/tui/client_ui.rs index cfa1ea9af..33e74cb3c 100644 --- a/crates/tokscale-cli/src/tui/client_ui.rs +++ b/crates/tokscale-cli/src/tui/client_ui.rs @@ -114,6 +114,54 @@ pub const CLIENT_UI: [ClientUi; ClientId::COUNT] = [ display_name: "Gajae-Code", hotkey: 'g', }, + ClientUi { + display_name: "Grok Build", + hotkey: 'u', + }, + ClientUi { + display_name: "Jcode", + hotkey: 'j', + }, + ClientUi { + display_name: "Command Code", + hotkey: 'd', + }, + ClientUi { + display_name: "MiMo Code", + hotkey: 'm', + }, + ClientUi { + display_name: "Antigravity CLI", + hotkey: 'f', + }, + ClientUi { + display_name: "Junie", + hotkey: 'p', + }, + ClientUi { + display_name: "ZCode", + hotkey: 'q', + }, + ClientUi { + display_name: "OpenCodeReview", + hotkey: 'O', + }, + ClientUi { + display_name: "CodeBuddy", + hotkey: 'C', + }, + ClientUi { + display_name: "WorkBuddy", + hotkey: 'B', + }, + ClientUi { + display_name: "Devin CLI", + hotkey: 'D', + }, + ClientUi { + display_name: "Devin Desktop", + hotkey: 'E', + }, ]; pub fn display_name(client: ClientId) -> &'static str { diff --git a/crates/tokscale-cli/src/tui/codex_login.rs b/crates/tokscale-cli/src/tui/codex_login.rs new file mode 100644 index 000000000..b5936014d --- /dev/null +++ b/crates/tokscale-cli/src/tui/codex_login.rs @@ -0,0 +1,409 @@ +use anyhow::Result; + +#[derive(Debug, Clone)] +pub(crate) enum CodexLoginOutcome { + Imported(crate::commands::usage::codex::CodexAccountInfo), + Failed(String), +} + +#[derive(Debug)] +pub(crate) enum CodexLoginEvent { + Output(String), + Finished(CodexLoginOutcome), +} + +/// Shared handle to the spawned `codex login` child process. The login worker +/// polls it via `try_wait`; the TUI marks the slot cancelled and takes the +/// child out to kill it on dismiss or exit. +pub(crate) type CodexLoginChildSlot = std::sync::Arc>; + +#[derive(Default)] +pub(crate) struct CodexLoginChildState { + child: Option, + cancelled: bool, +} + +pub(crate) fn cancel_codex_login_child(slot: &CodexLoginChildSlot) { + let child = slot.lock().ok().and_then(|mut state| { + state.cancelled = true; + state.child.take() + }); + if let Some(mut child) = child { + let _ = child.kill(); + let _ = child.wait(); + } +} + +pub(crate) fn run_codex_login_worker( + tx: std::sync::mpsc::Sender, + child_slot: CodexLoginChildSlot, +) { + let result = run_codex_login_worker_inner(tx.clone(), child_slot); + let outcome = match result { + Ok(info) => CodexLoginOutcome::Imported(info), + Err(e) => CodexLoginOutcome::Failed(e.to_string()), + }; + let _ = tx.send(CodexLoginEvent::Finished(outcome)); +} + +fn run_codex_login_worker_inner( + tx: std::sync::mpsc::Sender, + child_slot: CodexLoginChildSlot, +) -> Result { + let codex_home = + std::env::temp_dir().join(format!("tokscale-codex-login-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&codex_home) + .map_err(|e| anyhow::anyhow!("failed to create temporary Codex home: {e}"))?; + + let result = run_codex_login_in_home(&codex_home, tx, child_slot); + let _ = std::fs::remove_dir_all(&codex_home); + result +} + +fn run_codex_login_in_home( + codex_home: &std::path::Path, + tx: std::sync::mpsc::Sender, + child_slot: CodexLoginChildSlot, +) -> Result { + let _ = tx.send(CodexLoginEvent::Output( + "Starting Codex browser login".to_string(), + )); + + let mut child = std::process::Command::new("codex") + .arg("login") + .env("CODEX_HOME", codex_home) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| anyhow::anyhow!("failed to start codex login: {e}"))?; + + let output_lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut readers = Vec::new(); + if let Some(stdout) = child.stdout.take() { + readers.push(spawn_codex_login_output_reader( + stdout, + tx.clone(), + std::sync::Arc::clone(&output_lines), + )); + } + if let Some(stderr) = child.stderr.take() { + readers.push(spawn_codex_login_output_reader( + stderr, + tx.clone(), + std::sync::Arc::clone(&output_lines), + )); + } + + if let Some(mut cancelled_child) = put_codex_login_child(&child_slot, child)? { + let _ = cancelled_child.kill(); + let _ = cancelled_child.wait(); + for reader in readers { + let _ = reader.join(); + } + anyhow::bail!("Codex login cancelled"); + } + + let status = wait_for_codex_login_child(&child_slot); + for reader in readers { + let _ = reader.join(); + } + let Some(status) = status? else { + // The TUI emptied the slot: the login was dismissed or the app exited. + anyhow::bail!("Codex login cancelled"); + }; + + if !status.success() { + let output_lines = output_lines + .lock() + .map(|lines| lines.clone()) + .unwrap_or_default(); + anyhow::bail!("{}", codex_login_failure_message(&status, &output_lines)); + } + + // `wait_for_codex_login_child` only observes cancellation while the child + // is still running. If the child exited successfully in the same tick the + // user dismissed, the import must not persist the account. The cancelled + // check and the import run under the same lock so a concurrent dismiss + // cannot slip between the check and the persistent save: either the cancel + // wins (we bail before importing) or the import wins (the dismiss blocks + // until the save completes, and a successful login is kept). + let auth_path = codex_home.join("auth.json"); + let Some(import) = import_unless_cancelled(&child_slot, &auth_path)? else { + anyhow::bail!("Codex login cancelled"); + }; + if let Some(warning) = import.warning { + let _ = tx.send(CodexLoginEvent::Output(warning)); + } + Ok(import.info) +} + +fn put_codex_login_child( + child_slot: &CodexLoginChildSlot, + child: std::process::Child, +) -> Result> { + let mut state = child_slot + .lock() + .map_err(|_| anyhow::anyhow!("codex login state lock poisoned"))?; + if state.cancelled { + Ok(Some(child)) + } else { + state.child = Some(child); + Ok(None) + } +} + +/// Imports the login auth file unless the slot was cancelled, holding the slot +/// lock across the cancelled check and the persistent import so the two are +/// atomic with respect to [`cancel_codex_login_child`]. Returns `Ok(None)` when +/// the login was cancelled (so the caller bails without persisting an account); +/// a poisoned lock is treated as cancelled to err away from a side effect. +fn import_unless_cancelled( + child_slot: &CodexLoginChildSlot, + auth_path: &std::path::Path, +) -> Result> { + let Ok(state) = child_slot.lock() else { + return Ok(None); + }; + if state.cancelled { + return Ok(None); + } + // Hold the guard across the import: a concurrent dismiss blocks on the lock + // until the save completes, closing the check-then-save race window. + let import = crate::commands::usage::codex::import_login_auth_file(auth_path)?; + drop(state); + Ok(Some(import)) +} + +/// Polls the login child until it exits. Returns `Ok(None)` when the TUI +/// cancelled the login on dismiss or app exit. +fn wait_for_codex_login_child( + child_slot: &CodexLoginChildSlot, +) -> Result> { + loop { + { + let mut state = child_slot + .lock() + .map_err(|_| anyhow::anyhow!("codex login state lock poisoned"))?; + if state.cancelled { + return Ok(None); + } + let Some(child) = state.child.as_mut() else { + return Ok(None); + }; + match child.try_wait() { + Ok(Some(status)) => { + state.child = None; + return Ok(Some(status)); + } + Ok(None) => {} + Err(e) => { + state.child = None; + return Err(anyhow::anyhow!("failed to wait for codex login: {e}")); + } + } + } + std::thread::sleep(std::time::Duration::from_millis(150)); + } +} + +fn spawn_codex_login_output_reader( + reader: R, + tx: std::sync::mpsc::Sender, + output_lines: std::sync::Arc>>, +) -> std::thread::JoinHandle<()> +where + R: std::io::Read + Send + 'static, +{ + std::thread::spawn(move || { + let reader = std::io::BufReader::new(reader); + for line in std::io::BufRead::lines(reader).map_while(std::result::Result::ok) { + let line = sanitize_codex_login_line(&line); + if !line.trim().is_empty() { + if let Ok(mut output_lines) = output_lines.lock() { + output_lines.push(line.clone()); + } + let _ = tx.send(CodexLoginEvent::Output(line)); + } + } + }) +} + +fn codex_login_failure_message( + status: &std::process::ExitStatus, + output_lines: &[String], +) -> String { + codex_login_failure_message_from_output(&status.to_string(), output_lines) +} + +fn codex_login_failure_message_from_output(status: &str, output_lines: &[String]) -> String { + let output = output_lines.join("\n").to_lowercase(); + + if output.contains("429") || output.contains("too many requests") { + return "OpenAI login is rate-limited (429 Too Many Requests). Wait before trying Add Codex again.".to_string(); + } + + if output.contains("expired") { + return "Codex device code expired. Start Add Codex again to get a new code.".to_string(); + } + + if output.contains("device auth failed") { + return "Codex device login failed. Try Add Codex again later.".to_string(); + } + + format!("codex login exited with {status}") +} + +fn sanitize_codex_login_line(line: &str) -> String { + let mut sanitized = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + match chars.next() { + Some('[') => { + for ch in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&ch) { + break; + } + } + } + Some(']') => { + while let Some(ch) = chars.next() { + if ch == '\x07' { + break; + } + if ch == '\x1b' && chars.peek() == Some(&'\\') { + let _ = chars.next(); + break; + } + } + } + Some(_) | None => {} + } + continue; + } + + if !ch.is_control() || ch == '\t' { + sanitized.push(ch); + } + } + + sanitized +} + +#[cfg(test)] +pub(crate) fn put_codex_login_child_for_test( + child_slot: &CodexLoginChildSlot, + child: std::process::Child, +) -> Result> { + put_codex_login_child(child_slot, child) +} + +#[cfg(test)] +pub(crate) fn codex_login_slot_child_is_none_for_test(child_slot: &CodexLoginChildSlot) -> bool { + child_slot + .lock() + .map(|state| state.child.is_none()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wait_for_codex_login_child_returns_none_when_cancelled() { + let slot = CodexLoginChildSlot::default(); + cancel_codex_login_child(&slot); + let status = wait_for_codex_login_child(&slot).unwrap(); + assert!(status.is_none()); + } + + #[test] + fn import_unless_cancelled_short_circuits_when_cancelled() { + // When the slot is cancelled, the import must be skipped entirely — it + // returns Ok(None) without touching the auth path (so no account is + // persisted). A path that does not exist would error if read, proving + // the cancelled check short-circuits before the import. + let slot = CodexLoginChildSlot::default(); + cancel_codex_login_child(&slot); + let missing = std::path::Path::new("/nonexistent/tokscale-codex-login/auth.json"); + + let result = import_unless_cancelled(&slot, missing).unwrap(); + + assert!( + result.is_none(), + "cancelled slot must skip the import side effect" + ); + } + + #[cfg(unix)] + #[test] + fn put_codex_login_child_returns_child_when_already_cancelled() { + let slot = CodexLoginChildSlot::default(); + cancel_codex_login_child(&slot); + let child = std::process::Command::new("true").spawn().unwrap(); + + let mut child = put_codex_login_child(&slot, child) + .unwrap() + .expect("cancelled slot should return the child to be killed by caller"); + + assert!(child.wait().unwrap().success()); + } + + #[cfg(unix)] + #[test] + fn wait_for_codex_login_child_reports_exit_status() { + let child = std::process::Command::new("true").spawn().unwrap(); + let slot = CodexLoginChildSlot::default(); + put_codex_login_child(&slot, child).unwrap(); + + let status = wait_for_codex_login_child(&slot).unwrap(); + + assert!(status.unwrap().success()); + assert!(slot.lock().unwrap().child.is_none()); + } + + #[test] + fn test_sanitize_codex_login_line_strips_ansi_sequences() { + assert_eq!( + sanitize_codex_login_line("\u{1b}[94mhttps://auth.openai.com/codex/device\u{1b}[0m"), + "https://auth.openai.com/codex/device" + ); + assert_eq!( + sanitize_codex_login_line("\u{1b}[90mCAGW-LNUYX\u{1b}[0m"), + "CAGW-LNUYX" + ); + } + + #[test] + fn test_codex_login_failure_message_identifies_rate_limit() { + let message = codex_login_failure_message_from_output( + "exit status: 1", + &[ + "Device codes are a common phishing target. Never share this code.".to_string(), + "Error logging in with device code: device auth failed with status 429 Too Many Requests" + .to_string(), + ], + ); + + assert_eq!( + message, + "OpenAI login is rate-limited (429 Too Many Requests). Wait before trying Add Codex again." + ); + } + + #[test] + fn test_codex_login_failure_message_identifies_expired_code() { + let message = codex_login_failure_message_from_output( + "exit status: 1", + &["Error logging in with device code: expired".to_string()], + ); + + assert_eq!( + message, + "Codex device code expired. Start Add Codex again to get a new code." + ); + } +} diff --git a/crates/tokscale-cli/src/tui/colors.rs b/crates/tokscale-cli/src/tui/colors.rs index f2d14bd6d..2d8a2a0e8 100644 --- a/crates/tokscale-cli/src/tui/colors.rs +++ b/crates/tokscale-cli/src/tui/colors.rs @@ -3,20 +3,25 @@ use std::collections::HashMap; use ratatui::style::Color; use super::data::ModelUsage; -use super::ui::widgets::{get_provider_from_model, get_provider_shade}; +use super::ui::widgets::{get_provider_from_model, get_provider_shade, provider_has_palette}; pub fn model_shade_key(provider: &str, model: &str) -> String { format!("{provider}\0{model}") } /// Builds a `(provider, model) -> Color` map where each provider's models are -/// cost-ranked; rank 0 (highest cost) gets the base provider color and later -/// ranks get progressively lighter shades. +/// ranked into the provider's shade ramp; rank 0 gets the base provider color +/// and later ranks get progressively lighter shades. +/// +/// Ranking encodes the model hierarchy, not spend: family tier first (for +/// Anthropic: fable > opus > sonnet > haiku), then version (newer = darker), +/// then cost, then model name. Cost is only a tiebreaker so that variants of +/// the same version (e.g. `-thinking`, `-high`) get stable adjacent shades. /// /// Aggregates cost per (provider, model) so the same model appearing in /// multiple group-by buckets (e.g. `GroupBy::WorkspaceModel`) doesn't inflate -/// the rank count. Ties on cost are resolved by model name so shade assignment -/// stays deterministic across refreshes. +/// the rank count. Remaining ties are resolved by model name so shade +/// assignment stays deterministic across refreshes. pub fn build_model_shade_map(models: &[ModelUsage]) -> HashMap { let mut by_provider: HashMap<&str, HashMap<&str, f64>> = HashMap::new(); for m in models { @@ -32,7 +37,13 @@ pub fn build_model_shade_map(models: &[ModelUsage]) -> HashMap { let mut map = HashMap::new(); for (provider, models_map) in by_provider { let mut ranked: Vec<(&str, f64)> = models_map.into_iter().collect(); - ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0))); + ranked.sort_by(|a, b| { + family_tier(provider, a.0) + .cmp(&family_tier(provider, b.0)) + .then_with(|| model_version(b.0).cmp(&model_version(a.0))) + .then_with(|| b.1.total_cmp(&a.1)) + .then_with(|| a.0.cmp(b.0)) + }); for (rank, (name, _)) in ranked.iter().enumerate() { map.insert( model_shade_key(provider, name), @@ -43,10 +54,288 @@ pub fn build_model_shade_map(models: &[ModelUsage]) -> HashMap { map } -fn provider_color_key<'a>(provider: &'a str, model: &'a str) -> &'a str { - if provider.is_empty() || provider.contains(", ") { +/// Resolves the provider whose color ramp a model's name should render in. +/// +/// Empty or mixed (`", "`-joined) providers color by the model's own vendor. +/// So do gateway providers that resell other vendors' models (e.g. +/// `github-copilot`, `openrouter`): they have no vendor palette of their own, so +/// a Claude model served through Copilot still gets the Anthropic ramp instead +/// of the neutral "unknown" gray. Both the shade-map build and the per-cell +/// lookup route through this, so their keys always agree. +pub(crate) fn provider_color_key<'a>(provider: &'a str, model: &'a str) -> &'a str { + if provider.is_empty() || provider.contains(", ") || !provider_has_palette(provider) { get_provider_from_model(model) } else { provider } } + +/// Position of a model's family within its provider's lineup; lower tiers get +/// darker shades. Only Anthropic has a known hierarchy — other providers rank +/// purely by version and cost. +fn family_tier(provider: &str, model: &str) -> u8 { + if !provider.to_lowercase().contains("anthropic") { + return 0; + } + let lower = model.to_lowercase(); + // "fable" is matched as a delimited token (mirrors get_provider_from_model) + // so ids like "unfabled-x" don't land in the flagship tier. + if lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|token| token == "fable") + { + 0 + } else if lower.contains("opus") { + 1 + } else if lower.contains("sonnet") { + 2 + } else if lower.contains("haiku") { + 3 + } else { + 4 + } +} + +/// Extracts a `(major, minor)` version from a model id: +/// "claude-opus-4-6" -> (4, 6), "gpt-5.4" -> (5, 4), "gpt-4o" -> (4, 0), +/// "claude-fable-5" -> (5, 0). Tokens only need to *start* with digits, so +/// alphanumeric versions like "4o" parse as their numeric prefix. Scanning +/// stops at the first 4+ digit value (dates like 20241022) so ids such as +/// "o1-2024-12-17" don't misparse a date fragment as a version; ids without +/// a version yield (0, 0). +fn model_version(model: &str) -> (u32, u32) { + let tokens: Vec<&str> = model + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|t| !t.is_empty()) + .collect(); + for (i, token) in tokens.iter().enumerate() { + let Some(major) = leading_number(token) else { + continue; + }; + if major >= 1000 { + return (0, 0); + } + // Minor must be fully numeric: suffix tokens like "1m" (from a + // "[1m]" context-window marker) are not version fragments. + let minor = tokens + .get(i + 1) + .and_then(|t| t.parse::().ok()) + .filter(|&m| m < 1000) + .unwrap_or(0); + return (major, minor); + } + (0, 0) +} + +/// Parses the leading digit run of a token: "4o" -> Some(4), "5" -> Some(5), +/// "turbo" -> None. +fn leading_number(token: &str) -> Option { + let end = token + .char_indices() + .find(|(_, c)| !c.is_ascii_digit()) + .map_or(token.len(), |(i, _)| i); + token[..end].parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::data::{ModelUsage, TokenBreakdown}; + + fn usage(model: &str, provider: &str, cost: f64) -> ModelUsage { + ModelUsage { + model: model.to_string(), + provider: provider.to_string(), + client: "claude".to_string(), + workspace_key: None, + workspace_label: None, + tokens: TokenBreakdown::default(), + cost, + performance: Default::default(), + session_count: 1, + } + } + + fn shade(map: &HashMap, provider: &str, model: &str) -> Color { + map.get(&model_shade_key(provider, model)).copied().unwrap() + } + + #[test] + fn provider_color_key_routes_gateways_to_model_vendor() { + // Known vendor providers keep their own identity. + assert_eq!( + provider_color_key("anthropic", "claude-opus-4-8"), + "anthropic" + ); + assert_eq!(provider_color_key("openai", "gpt-5.4"), "openai"); + // Gateway providers with no vendor palette color by the model's vendor. + assert_eq!( + provider_color_key("github-copilot", "claude-fable-5"), + "anthropic" + ); + assert_eq!( + provider_color_key("github-copilot", "gpt-5.3-codex"), + "openai" + ); + // Empty / mixed providers also defer to the model. + assert_eq!(provider_color_key("", "claude-fable-5"), "anthropic"); + assert_eq!( + provider_color_key("anthropic, github-copilot", "claude-fable-5"), + "anthropic" + ); + } + + #[test] + fn gateway_and_native_claude_share_one_shade_bucket() { + // Regression: the same Claude model served natively and via the + // github-copilot gateway must fold into one Anthropic-ramp bucket and + // rank by family tier, not split into a separate "unknown" gray key. + let map = build_model_shade_map(&[ + usage("claude-fable-5", "github-copilot", 3.0), + usage("claude-opus-4-8", "anthropic", 100.0), + ]); + assert_eq!( + shade(&map, "anthropic", "claude-fable-5"), + get_provider_shade("anthropic", 0), + "copilot fable takes the flagship Anthropic shade" + ); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-8"), + get_provider_shade("anthropic", 1), + "opus ranks below fable in the shared bucket" + ); + } + + #[test] + fn model_version_parses_common_id_shapes() { + assert_eq!(model_version("claude-opus-4-6"), (4, 6)); + assert_eq!(model_version("claude-4-5-opus-high-thinking"), (4, 5)); + assert_eq!(model_version("claude-fable-5"), (5, 0)); + assert_eq!(model_version("claude-fable-5[1m]"), (5, 0)); + assert_eq!(model_version("gpt-5.4"), (5, 4)); + assert_eq!(model_version("claude-3-5-sonnet-20241022"), (3, 5)); + assert_eq!(model_version("gpt-4-turbo"), (4, 0)); + // Alphanumeric version tokens parse by their numeric prefix. + assert_eq!(model_version("gpt-4o"), (4, 0)); + assert_eq!(model_version("gpt-4o-mini"), (4, 0)); + assert_eq!(model_version("gpt-3.5-turbo"), (3, 5)); + // A "[1m]" context marker is not a minor version. + assert_eq!(model_version("gpt-4o[1m]"), (4, 0)); + // Date fragments must not be read as versions. + assert_eq!(model_version("o1-2024-12-17"), (0, 0)); + assert_eq!(model_version("codex-mini-latest"), (0, 0)); + } + + #[test] + fn fable_outranks_higher_cost_opus() { + // Fable is the flagship tier: it takes the base shade even when the + // user has spent far more on Opus models. + let map = build_model_shade_map(&[ + usage("claude-opus-4-6", "anthropic", 900.0), + usage("claude-fable-5", "anthropic", 1.0), + ]); + assert_eq!( + shade(&map, "anthropic", "claude-fable-5"), + get_provider_shade("anthropic", 0) + ); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-6"), + get_provider_shade("anthropic", 1) + ); + } + + #[test] + fn newer_opus_outranks_older_despite_lower_cost() { + let map = build_model_shade_map(&[ + usage("claude-opus-4-5", "anthropic", 500.0), + usage("claude-opus-4-7", "anthropic", 10.0), + usage("claude-opus-4-6", "anthropic", 300.0), + ]); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-7"), + get_provider_shade("anthropic", 0) + ); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-6"), + get_provider_shade("anthropic", 1) + ); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-5"), + get_provider_shade("anthropic", 2) + ); + } + + #[test] + fn family_tier_beats_version_within_anthropic() { + // A newer Sonnet never renders darker than an older Opus. + let map = build_model_shade_map(&[ + usage("claude-sonnet-5", "anthropic", 800.0), + usage("claude-opus-4-1", "anthropic", 2.0), + ]); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-1"), + get_provider_shade("anthropic", 0) + ); + assert_eq!( + shade(&map, "anthropic", "claude-sonnet-5"), + get_provider_shade("anthropic", 1) + ); + } + + #[test] + fn variant_suffixes_group_with_their_base_version() { + // Same version, different variants: cost then name break the tie, so + // the 4-5 family occupies adjacent shades below 4-6. + let map = build_model_shade_map(&[ + usage("claude-opus-4-5-thinking-high", "anthropic", 50.0), + usage("claude-opus-4-6", "anthropic", 10.0), + usage("claude-opus-4-5", "anthropic", 100.0), + ]); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-6"), + get_provider_shade("anthropic", 0) + ); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-5"), + get_provider_shade("anthropic", 1) + ); + assert_eq!( + shade(&map, "anthropic", "claude-opus-4-5-thinking-high"), + get_provider_shade("anthropic", 2) + ); + } + + #[test] + fn non_anthropic_providers_rank_by_version_first() { + let map = build_model_shade_map(&[ + usage("gpt-4", "openai", 700.0), + usage("gpt-5.4", "openai", 5.0), + ]); + assert_eq!( + shade(&map, "openai", "gpt-5.4"), + get_provider_shade("openai", 0) + ); + assert_eq!( + shade(&map, "openai", "gpt-4"), + get_provider_shade("openai", 1) + ); + } + + #[test] + fn alphanumeric_versions_outrank_older_numeric_ones() { + // Regression (PR #810 review): "4o" used to parse as no version at + // all, letting gpt-3.5-turbo render darker than gpt-4o. + let map = build_model_shade_map(&[ + usage("gpt-3.5-turbo", "openai", 900.0), + usage("gpt-4o", "openai", 1.0), + ]); + assert_eq!( + shade(&map, "openai", "gpt-4o"), + get_provider_shade("openai", 0) + ); + assert_eq!( + shade(&map, "openai", "gpt-3.5-turbo"), + get_provider_shade("openai", 1) + ); + } +} diff --git a/crates/tokscale-cli/src/tui/data/mod.rs b/crates/tokscale-cli/src/tui/data/mod.rs index 0100a3b52..8ebf73201 100644 --- a/crates/tokscale-cli/src/tui/data/mod.rs +++ b/crates/tokscale-cli/src/tui/data/mod.rs @@ -132,6 +132,15 @@ pub struct MinutelyUsage { pub turn_count: u32, } +#[derive(Debug, Clone)] +pub struct MonthlyUsage { + pub month: String, + pub tokens: TokenBreakdown, + pub cost: f64, + pub message_count: u32, + pub turn_count: u32, +} + #[derive(Debug, Clone)] pub struct ContributionDay { pub date: NaiveDate, @@ -152,6 +161,7 @@ pub struct UsageData { pub daily: Vec, pub hourly: Vec, pub minutely: Vec, + pub monthly: Vec, pub graph: Option, pub total_tokens: u64, pub total_cost: f64, @@ -190,11 +200,16 @@ fn workspace_bucket(msg: &UnifiedMessage) -> (String, Option, String) { } fn positive_unified_token_total(tokens: &tokscale_core::TokenBreakdown) -> i64 { - tokens.input.max(0) - + tokens.output.max(0) - + tokens.cache_read.max(0) - + tokens.cache_write.max(0) - + tokens.reasoning.max(0) + // saturating_add (mirrors tokscale_core::TokenBreakdown::total) so a + // clamped (i64::MAX) bucket from a corrupt source can't overflow the + // per-message sum. + tokens + .input + .max(0) + .saturating_add(tokens.output.max(0)) + .saturating_add(tokens.cache_read.max(0)) + .saturating_add(tokens.cache_write.max(0)) + .saturating_add(tokens.reasoning.max(0)) } fn workspace_model_display_label(workspace_label: &str, model: &str) -> String { @@ -507,6 +522,8 @@ impl DataLoader { if let Some(agent) = msg.agent.as_ref() { let normalized_agent = if msg.client == "opencode" { sessions::normalize_opencode_agent_name(agent) + } else if msg.client == "copilot" { + sessions::normalize_copilot_agent_name(agent) } else { sessions::normalize_agent_name(agent) }; @@ -894,6 +911,8 @@ impl DataLoader { let mut minutely: Vec = minutely_map.into_values().collect(); minutely.sort_by_key(|b| std::cmp::Reverse(b.datetime)); + let monthly = aggregate_monthly_from_daily(&daily); + let total_tokens: u64 = models.iter().map(|m| m.tokens.total()).sum(); let total_cost: f64 = models .iter() @@ -909,6 +928,7 @@ impl DataLoader { daily, hourly, minutely, + monthly, graph: Some(graph), total_tokens, total_cost, @@ -1052,6 +1072,42 @@ fn calculate_streaks(daily: &[DailyUsage]) -> (u32, u32) { calculate_streaks_for_today(daily, Local::now().date_naive()) } +pub fn aggregate_monthly_from_daily(daily: &[DailyUsage]) -> Vec { + let mut monthly_map: HashMap = HashMap::new(); + + for day in daily { + let month = day.date.format("%Y-%m").to_string(); + let entry = monthly_map + .entry(month.clone()) + .or_insert_with(|| MonthlyUsage { + month, + tokens: TokenBreakdown::default(), + cost: 0.0, + message_count: 0, + turn_count: 0, + }); + + entry.tokens.input = entry.tokens.input.saturating_add(day.tokens.input); + entry.tokens.output = entry.tokens.output.saturating_add(day.tokens.output); + entry.tokens.cache_read = entry + .tokens + .cache_read + .saturating_add(day.tokens.cache_read); + entry.tokens.cache_write = entry + .tokens + .cache_write + .saturating_add(day.tokens.cache_write); + entry.tokens.reasoning = entry.tokens.reasoning.saturating_add(day.tokens.reasoning); + entry.cost += day.cost; + entry.message_count = entry.message_count.saturating_add(day.message_count); + entry.turn_count = entry.turn_count.saturating_add(day.turn_count); + } + + let mut monthly: Vec = monthly_map.into_values().collect(); + monthly.sort_by(|a, b| b.month.cmp(&a.month)); + monthly +} + fn calculate_streaks_for_today(daily: &[DailyUsage], today: NaiveDate) -> (u32, u32) { if daily.is_empty() { return (0, 0); @@ -1208,6 +1264,20 @@ mod tests { use tokscale_core::pricing::{ModelPricing, PricingService}; use tokscale_core::TokenBreakdown as CoreTokenBreakdown; + #[test] + fn positive_unified_token_total_saturates_instead_of_overflowing() { + // tokscale-core clamps corrupt per-field token buckets to i64::MAX; a + // plain `+` fold over two clamped buckets would panic in debug builds. + let tokens = CoreTokenBreakdown { + input: i64::MAX, + output: 0, + cache_read: i64::MAX, + cache_write: -5, + reasoning: 0, + }; + assert_eq!(positive_unified_token_total(&tokens), i64::MAX); + } + fn test_pricing_service() -> PricingService { let mut litellm = HashMap::new(); litellm.insert( @@ -1337,7 +1407,7 @@ mod tests { #[test] fn test_client_all() { let clients = ClientId::ALL; - assert_eq!(clients.len(), 27); + assert_eq!(clients.len(), ClientId::COUNT); assert_eq!(clients[0], ClientId::OpenCode); assert_eq!(clients[1], ClientId::Claude); assert_eq!(clients[2], ClientId::Codex); @@ -1365,6 +1435,16 @@ mod tests { assert_eq!(clients[24], ClientId::Warp); assert_eq!(clients[25], ClientId::Cline); assert_eq!(clients[26], ClientId::Gjc); + assert_eq!(clients[27], ClientId::Grok); + assert_eq!(clients[28], ClientId::Jcode); + assert_eq!(clients[29], ClientId::CommandCode); + assert_eq!(clients[30], ClientId::MiMoCode); + assert_eq!(clients[31], ClientId::AntigravityCli); + assert_eq!(clients[32], ClientId::Junie); + assert_eq!(clients[33], ClientId::Zcode); + assert_eq!(clients[34], ClientId::OpenCodeReview); + assert_eq!(clients[35], ClientId::CodeBuddy); + assert_eq!(clients[36], ClientId::WorkBuddy); } #[test] @@ -1444,6 +1524,30 @@ mod tests { crate::tui::client_ui::display_name(ClientId::Cline), "Cline" ); + assert_eq!( + crate::tui::client_ui::display_name(ClientId::Grok), + "Grok Build" + ); + assert_eq!( + crate::tui::client_ui::display_name(ClientId::Jcode), + "Jcode" + ); + assert_eq!( + crate::tui::client_ui::display_name(ClientId::AntigravityCli), + "Antigravity CLI" + ); + assert_eq!( + crate::tui::client_ui::display_name(ClientId::Junie), + "Junie" + ); + assert_eq!( + crate::tui::client_ui::display_name(ClientId::CodeBuddy), + "CodeBuddy" + ); + assert_eq!( + crate::tui::client_ui::display_name(ClientId::WorkBuddy), + "WorkBuddy" + ); } #[test] @@ -1473,6 +1577,12 @@ mod tests { assert_eq!(crate::tui::client_ui::hotkey(ClientId::Trae), 'y'); assert_eq!(crate::tui::client_ui::hotkey(ClientId::Cline), 'n'); assert_eq!(crate::tui::client_ui::hotkey(ClientId::Gjc), 'g'); + assert_eq!(crate::tui::client_ui::hotkey(ClientId::Grok), 'u'); + assert_eq!(crate::tui::client_ui::hotkey(ClientId::Jcode), 'j'); + assert_eq!(crate::tui::client_ui::hotkey(ClientId::AntigravityCli), 'f'); + assert_eq!(crate::tui::client_ui::hotkey(ClientId::Junie), 'p'); + assert_eq!(crate::tui::client_ui::hotkey(ClientId::CodeBuddy), 'C'); + assert_eq!(crate::tui::client_ui::hotkey(ClientId::WorkBuddy), 'B'); } #[test] @@ -1557,6 +1667,30 @@ mod tests { crate::tui::client_ui::from_hotkey('y'), Some(ClientId::Trae) ); + assert_eq!( + crate::tui::client_ui::from_hotkey('u'), + Some(ClientId::Grok) + ); + assert_eq!( + crate::tui::client_ui::from_hotkey('j'), + Some(ClientId::Jcode) + ); + assert_eq!( + crate::tui::client_ui::from_hotkey('f'), + Some(ClientId::AntigravityCli) + ); + assert_eq!( + crate::tui::client_ui::from_hotkey('p'), + Some(ClientId::Junie) + ); + assert_eq!( + crate::tui::client_ui::from_hotkey('C'), + Some(ClientId::CodeBuddy) + ); + assert_eq!( + crate::tui::client_ui::from_hotkey('B'), + Some(ClientId::WorkBuddy) + ); } #[test] @@ -2382,6 +2516,7 @@ after"#, fn test_data_loader_keeps_synthetic_gateway_messages_under_original_client() { let temp_dir = TempDir::new().unwrap(); let previous_home = env::var_os("HOME"); + let previous_xdg_data_home = env::var_os("XDG_DATA_HOME"); let message_dir = temp_dir .path() .join(".local/share/opencode/storage/message/project-1"); @@ -2394,6 +2529,7 @@ after"#, unsafe { env::set_var("HOME", temp_dir.path()); + env::remove_var("XDG_DATA_HOME"); } let pricing = test_pricing_service(); @@ -2410,7 +2546,7 @@ after"#, let expected_cost = expected_message_cost( &pricing, "accounts/fireworks/models/deepseek-v3-0324", - "fireworks", + "fireworks_ai", CoreTokenBreakdown { input: 10, output: 5, @@ -2422,7 +2558,9 @@ after"#, assert_eq!(usage.models.len(), 1); assert_eq!(usage.models[0].client, "opencode"); - assert_eq!(usage.models[0].provider, "fireworks"); + // opencode now canonicalizes the provider (fireworks -> fireworks_ai), + // matching every other session parser. + assert_eq!(usage.models[0].provider, "fireworks_ai"); assert_eq!(usage.models[0].model, "deepseek-v3-0324"); assert_eq!(usage.models[0].tokens.total(), 15); assert_cost_matches(usage.models[0].cost, expected_cost); @@ -2431,6 +2569,10 @@ after"#, Some(home) => unsafe { env::set_var("HOME", home) }, None => unsafe { env::remove_var("HOME") }, } + match previous_xdg_data_home { + Some(path) => unsafe { env::set_var("XDG_DATA_HOME", path) }, + None => unsafe { env::remove_var("XDG_DATA_HOME") }, + } } #[test] @@ -2572,4 +2714,115 @@ after"#, assert_eq!(bucket.tokens.output, 0); assert_eq!(bucket.cost, 0.0); } + + fn daily_usage( + date: NaiveDate, + input: u64, + output: u64, + cost: f64, + message_count: u32, + ) -> DailyUsage { + DailyUsage { + date, + tokens: TokenBreakdown { + input, + output, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }, + cost, + source_breakdown: BTreeMap::new(), + message_count, + turn_count: 0, + } + } + + #[test] + fn test_aggregate_monthly_from_daily_groups_by_month() { + let daily = vec![ + daily_usage( + NaiveDate::from_ymd_opt(2026, 5, 10).unwrap(), + 100, + 50, + 1.0, + 2, + ), + daily_usage( + NaiveDate::from_ymd_opt(2026, 5, 20).unwrap(), + 200, + 100, + 2.0, + 3, + ), + daily_usage( + NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + 300, + 150, + 3.0, + 4, + ), + ]; + + let monthly = aggregate_monthly_from_daily(&daily); + assert_eq!(monthly.len(), 2); + + let may = monthly.iter().find(|m| m.month == "2026-05").unwrap(); + assert_eq!(may.tokens.input, 300); + assert_eq!(may.tokens.output, 150); + assert_eq!(may.tokens.total(), 450); + assert_eq!(may.cost, 3.0); + assert_eq!(may.message_count, 5); + + let june = monthly.iter().find(|m| m.month == "2026-06").unwrap(); + assert_eq!(june.tokens.input, 300); + assert_eq!(june.tokens.output, 150); + assert_eq!(june.tokens.total(), 450); + assert_eq!(june.cost, 3.0); + assert_eq!(june.message_count, 4); + } + + #[test] + fn test_aggregate_messages_populates_monthly() { + let loader = DataLoader::new(None); + let base_ms = 1_736_899_200_000_i64; // mid-January 2025 in UTC + let usage = loader + .aggregate_messages( + vec![ + make_msg(base_ms, 100, 50, 1.0), + make_msg(base_ms + 86_400_000, 200, 100, 2.0), // +1 day + ], + &GroupBy::Model, + ) + .unwrap(); + + assert_eq!(usage.monthly.len(), 1); + assert_eq!(usage.monthly[0].month, "2025-01"); + assert_eq!(usage.monthly[0].tokens.input, 300); + assert_eq!(usage.monthly[0].cost, 3.0); + } + + #[test] + fn test_aggregate_monthly_sorts_descending() { + let daily = vec![ + daily_usage( + NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + 100, + 50, + 1.0, + 1, + ), + daily_usage( + NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), + 200, + 100, + 2.0, + 1, + ), + ]; + + let monthly = aggregate_monthly_from_daily(&daily); + assert_eq!(monthly[0].month, "2026-05"); + assert_eq!(monthly[1].month, "2026-03"); + } } diff --git a/crates/tokscale-cli/src/tui/export.rs b/crates/tokscale-cli/src/tui/export.rs index c2faf0a8a..736f919ae 100644 --- a/crates/tokscale-cli/src/tui/export.rs +++ b/crates/tokscale-cli/src/tui/export.rs @@ -49,6 +49,19 @@ pub fn build_export_json(data: &UsageData) -> Result { "turnCount": d.turn_count, "cost": d.cost })).collect::>(), + "monthly": data.monthly.iter().map(|m| json!({ + "month": m.month, + "tokens": { + "input": m.tokens.input, + "output": m.tokens.output, + "cacheRead": m.tokens.cache_read, + "cacheWrite": m.tokens.cache_write, + "total": m.tokens.total() + }, + "messageCount": m.message_count, + "turnCount": m.turn_count, + "cost": m.cost + })).collect::>(), "totals": { "tokens": data.total_tokens, "cost": data.total_cost diff --git a/crates/tokscale-cli/src/tui/keymap.rs b/crates/tokscale-cli/src/tui/keymap.rs new file mode 100644 index 000000000..de9ac6cbb --- /dev/null +++ b/crates/tokscale-cli/src/tui/keymap.rs @@ -0,0 +1,236 @@ +//! Layout-independent hotkey normalization. +//! +//! A terminal never tells an app which *physical* key was pressed — it only +//! delivers the *character* that the active OS keyboard layout produced. So +//! pressing the physical `Q` key under a Russian layout arrives as +//! `KeyCode::Char('й')`, and a `match` on `KeyCode::Char('q')` silently never +//! fires. The result: every single-letter hotkey breaks the moment the user +//! switches to a non-Latin layout (Russian, Ukrainian, Kazakh, Tatar, Greek, +//! …). +//! +//! [`normalize_hotkey`] fixes this by mapping a produced character back to the +//! Latin letter that sits on the **same physical key** of a US-QWERTY layout. +//! `'й' -> 'q'`, `'с' -> 'c'`, `'χ' -> 'x'`, and so on. ASCII passes straight +//! through, so the English path is untouched. +//! +//! ## Where this is (and isn't) applied +//! +//! Only on the *command* path — global hotkeys and yes/no confirmations. Text +//! input (e.g. the client-picker filter) keeps the raw character, otherwise a +//! Russian user could never type Cyrillic into a search box. Navigation keys +//! (Tab/Enter/Esc/arrows/F-keys) are already layout-independent and pass +//! through unchanged. +//! +//! ## Adding a language +//! +//! Each script is a `&[(char, char)]` table of `(produced_char, us_qwerty_char)` +//! pairs, listed in lowercase; uppercase and case-preservation are handled +//! automatically. To support a new alphabetic layout, add a table and append +//! it to [`LAYOUTS`]. Different scripts live in disjoint Unicode blocks, so +//! tables never collide. +//! +//! ## Known limits +//! +//! - CJK input via an IME (Chinese pinyin, Japanese romaji) is composed by the +//! OS and the terminal delivers nothing per-keystroke until composition ends +//! — it cannot be intercepted here. Such users press hotkeys in the IME's +//! direct/half-width mode, which already yields Latin characters. +//! - A few positions have no letter in some scripts (e.g. the `Q` key carries +//! no Greek letter), so those specific hotkeys stay layout-bound; `Esc` and +//! `Ctrl+C` always work regardless. + +use crossterm::event::KeyCode; + +/// Cyrillic ЙЦУКЕН base layout. Shared by Russian, Ukrainian, Belarusian, +/// Kazakh, Tatar, Bashkir, Kyrgyz and other languages that extend it — their +/// extra glyphs sit on the number row, not on the letter keys, so the letter +/// positions below cover them all. The lone non-Russian entry is Ukrainian +/// `і`, which replaces `ы` on the `S` key. +/// +/// Bulgarian is intentionally excluded: its BDS and phonetic layouts place the +/// *same* Cyrillic letters on *different* keys, and a character-based map can +/// encode only one position per glyph (e.g. `я` is `Z` here but `S` on BDS). +/// Supporting it would require knowing the active layout, which the terminal +/// never reports — so it cannot share this table without misfiring hotkeys. +const CYRILLIC: &[(char, char)] = &[ + ('й', 'q'), + ('ц', 'w'), + ('у', 'e'), + ('к', 'r'), + ('е', 't'), + ('н', 'y'), + ('г', 'u'), + ('ш', 'i'), + ('щ', 'o'), + ('з', 'p'), + ('ф', 'a'), + ('ы', 's'), + ('і', 's'), + ('в', 'd'), + ('а', 'f'), + ('п', 'g'), + ('р', 'h'), + ('о', 'j'), + ('л', 'k'), + ('д', 'l'), + ('я', 'z'), + ('ч', 'x'), + ('с', 'c'), + ('м', 'v'), + ('и', 'b'), + ('т', 'n'), + ('ь', 'm'), +]; + +/// Standard Greek layout. Positional, mostly phonetic. The `Q` key carries no +/// Greek letter (it produces `;`), so there is intentionally no `-> 'q'` entry. +const GREEK: &[(char, char)] = &[ + ('ς', 'w'), + ('ε', 'e'), + ('ρ', 'r'), + ('τ', 't'), + ('υ', 'y'), + ('θ', 'u'), + ('ι', 'i'), + ('ο', 'o'), + ('π', 'p'), + ('α', 'a'), + ('σ', 's'), + ('δ', 'd'), + ('φ', 'f'), + ('γ', 'g'), + ('η', 'h'), + ('ξ', 'j'), + ('κ', 'k'), + ('λ', 'l'), + ('ζ', 'z'), + ('χ', 'x'), + ('ψ', 'c'), + ('ω', 'v'), + ('β', 'b'), + ('ν', 'n'), + ('μ', 'm'), +]; + +/// All known layouts, scanned in order. Append a new `&[(char, char)]` table +/// here to support another script. +const LAYOUTS: &[&[(char, char)]] = &[CYRILLIC, GREEK]; + +/// Map a produced character to the Latin letter on the same physical US-QWERTY +/// key, preserving case. ASCII and unknown characters are returned unchanged. +fn latin_at_position(c: char) -> char { + // Fast path: the canonical hotkey alphabet is ASCII and needs no mapping. + if c.is_ascii() { + return c; + } + + let lower = c.to_lowercase().next().unwrap_or(c); + for table in LAYOUTS { + if let Some(&(_, latin)) = table.iter().find(|(src, _)| *src == lower) { + return if c.is_uppercase() { + latin.to_ascii_uppercase() + } else { + latin + }; + } + } + c +} + +/// Normalize a key for *command* matching: a character key is remapped to its +/// US-QWERTY positional equivalent; every other key code is left as-is. +/// +/// Do not call this on text-input paths — there the literal character is what +/// the user means to type. +pub fn normalize_hotkey(code: KeyCode) -> KeyCode { + match code { + KeyCode::Char(c) => KeyCode::Char(latin_at_position(c)), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ascii_passes_through_unchanged() { + for c in ['q', 'c', 't', 'R', '+', '-', '=', ' ', '1'] { + assert_eq!(normalize_hotkey(KeyCode::Char(c)), KeyCode::Char(c)); + } + } + + #[test] + fn russian_letters_map_to_qwerty_positions() { + // The hotkeys this app actually uses, typed on a Russian layout. + let cases = [ + ('й', 'q'), + ('с', 'c'), + ('е', 't'), + ('в', 'd'), + ('о', 'j'), + ('з', 'p'), + ('к', 'r'), + ('н', 'y'), + ('у', 'e'), + ('ы', 's'), + ('р', 'h'), + ('м', 'v'), + ('п', 'g'), + ('ф', 'a'), + ('ь', 'm'), + ('ч', 'x'), + ]; + for (cyrillic, latin) in cases { + assert_eq!( + normalize_hotkey(KeyCode::Char(cyrillic)), + KeyCode::Char(latin), + "{cyrillic} should map to {latin}", + ); + } + } + + #[test] + fn ukrainian_i_maps_to_s_position() { + assert_eq!(normalize_hotkey(KeyCode::Char('і')), KeyCode::Char('s')); + } + + #[test] + fn greek_letters_map_to_qwerty_positions() { + let cases = [ + ('ς', 'w'), + ('ε', 'e'), + ('τ', 't'), + ('χ', 'x'), + ('ψ', 'c'), + ('δ', 'd'), + ('α', 'a'), + ('μ', 'm'), + ]; + for (greek, latin) in cases { + assert_eq!( + normalize_hotkey(KeyCode::Char(greek)), + KeyCode::Char(latin), + "{greek} should map to {latin}", + ); + } + } + + #[test] + fn case_is_preserved() { + // Shift+R toggles auto-refresh; on a Russian layout that is Shift+К. + assert_eq!(normalize_hotkey(KeyCode::Char('К')), KeyCode::Char('R')); + assert_eq!(normalize_hotkey(KeyCode::Char('Й')), KeyCode::Char('Q')); + // Greek uppercase rho -> R. + assert_eq!(normalize_hotkey(KeyCode::Char('Ρ')), KeyCode::Char('R')); + } + + #[test] + fn unknown_and_non_char_keys_are_untouched() { + // CJK glyph: no positional mapping exists, leave it alone. + assert_eq!(normalize_hotkey(KeyCode::Char('日')), KeyCode::Char('日')); + assert_eq!(normalize_hotkey(KeyCode::Tab), KeyCode::Tab); + assert_eq!(normalize_hotkey(KeyCode::Esc), KeyCode::Esc); + assert_eq!(normalize_hotkey(KeyCode::Enter), KeyCode::Enter); + } +} diff --git a/crates/tokscale-cli/src/tui/mod.rs b/crates/tokscale-cli/src/tui/mod.rs index ae6f867f9..9ef43ba4f 100644 --- a/crates/tokscale-cli/src/tui/mod.rs +++ b/crates/tokscale-cli/src/tui/mod.rs @@ -1,14 +1,18 @@ mod app; mod cache; pub mod client_ui; +pub(crate) mod codex_login; mod colors; pub mod config; pub mod data; mod event; mod export; +mod keymap; +pub(crate) mod privacy; +pub mod remote; pub mod settings; mod themes; -mod ui; +pub(crate) mod ui; pub use app::{App, Tab, TuiConfig}; pub use cache::{ @@ -160,6 +164,11 @@ pub fn run( } }; + // Cache-first load of server-side aggregated multi-device stats. The + // background refresh (when the cache is stale or missing) is driven by + // App::on_tick, and every failure path degrades silently to local-only. + app.init_remote_stats(); + let (bg_tx, bg_rx) = mpsc::channel::>(); if needs_background_load { @@ -214,6 +223,10 @@ pub fn run( &sigcont_flag, ); + // Don't orphan a `codex login` child (it would keep holding the OAuth + // port after the TUI exits). + app.kill_codex_login_child(); + restore_terminal(&mut terminal); result diff --git a/crates/tokscale-cli/src/tui/privacy.rs b/crates/tokscale-cli/src/tui/privacy.rs new file mode 100644 index 000000000..9a3b62a5f --- /dev/null +++ b/crates/tokscale-cli/src/tui/privacy.rs @@ -0,0 +1,4 @@ +pub(crate) fn looks_like_email(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.contains('@') && trimmed.split('@').count() == 2 +} diff --git a/crates/tokscale-cli/src/tui/remote.rs b/crates/tokscale-cli/src/tui/remote.rs new file mode 100644 index 000000000..dc7213b15 --- /dev/null +++ b/crates/tokscale-cli/src/tui/remote.rs @@ -0,0 +1,303 @@ +//! Server-side aggregated multi-device stats for the TUI. +//! +//! Fetches `GET /api/me/stats` (see +//! `packages/frontend/src/app/api/me/stats/route.ts`) with the stored CLI +//! token and caches the response on disk with a ~1h TTL so the TUI can render +//! cache-first and refresh in the background. Everything here degrades +//! silently: callers treat any error as "no remote data" and fall back to +//! local-only display. + +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Wire schema version this CLI understands (`schemaVersion` in the JSON). +const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +/// Cache freshness window. Server-side totals only change on `tokscale +/// submit`, so an hour keeps the footer indicator timely without hammering +/// the API on every TUI launch. +const CACHE_TTL_SECS: u64 = 3600; + +const CACHE_FILE_NAME: &str = "remote-stats-cache.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDayStat { + pub date: String, + pub tokens: u64, + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + pub cost: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDeviceStat { + pub id: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub last_submitted_at: Option, +} + +/// Aggregated stats across all of the user's devices, as returned by +/// `GET /api/me/stats` plus local cache metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteStats { + pub schema_version: u32, + pub total_tokens: u64, + pub total_cost: f64, + pub device_count: u64, + #[serde(default)] + pub last_submitted_at: Option, + #[serde(default)] + pub days: Vec, + #[serde(default)] + pub devices: Vec, + + // ── Cache metadata (not sent by the server) ──────────────────────── + /// Unix seconds when this payload was fetched. + #[serde(default)] + pub fetched_at_secs: u64, + /// Username the cache was fetched for; invalidates on account switch. + #[serde(default)] + pub cached_for_user: String, + /// API base URL the cache was fetched from; invalidates on server switch. + #[serde(default)] + pub cached_for_api_url: String, +} + +impl RemoteStats { + /// Whether this payload is older than the cache TTL and should be + /// refreshed in the background. + pub fn is_stale(&self) -> bool { + self.fetched_at_secs.saturating_add(CACHE_TTL_SECS) <= now_secs() + } +} + +/// Fetch `GET /api/me/stats` with the given bearer token and persist the +/// result to the on-disk cache. Built for use from a background thread: +/// spins up its own current-thread runtime, mirroring +/// `crate::commands::usage` provider fetchers. +// Only referenced from App's cfg(not(test)) fetch path — tests never hit the network. +#[cfg_attr(test, allow(dead_code))] +pub fn fetch_remote_stats(token: &str, username: &str, api_base_url: &str) -> Result { + let url = format!("{}/api/me/stats", api_base_url.trim_end_matches('/')); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + let mut stats: RemoteStats = rt.block_on(async { + let response = reqwest::Client::new() + .get(url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .context("Failed to fetch remote stats")?; + + if !response.status().is_success() { + anyhow::bail!("Remote stats request failed with {}", response.status()); + } + + response + .json::() + .await + .context("Failed to parse remote stats response") + })?; + + if stats.schema_version != SUPPORTED_SCHEMA_VERSION { + anyhow::bail!( + "Unsupported remote stats schema version {}", + stats.schema_version + ); + } + + stats.fetched_at_secs = now_secs(); + stats.cached_for_user = username.to_string(); + stats.cached_for_api_url = api_base_url.to_string(); + let _ = save_remote_stats_cache(&stats); + Ok(stats) +} + +/// Load the cached stats if they are fresh (within TTL) and were fetched for +/// the same account and API server. Returns `None` otherwise. +pub fn load_cached_remote_stats( + expected_user: &str, + expected_api_url: &str, +) -> Option { + if expected_user.is_empty() { + return None; + } + + let cache_path = get_cache_path()?; + let content = fs::read_to_string(cache_path).ok()?; + let stats: RemoteStats = serde_json::from_str(&content).ok()?; + + if stats.schema_version != SUPPORTED_SCHEMA_VERSION { + return None; + } + + if stats.fetched_at_secs.saturating_add(CACHE_TTL_SECS) <= now_secs() { + return None; + } + + // Reject cache belonging to a different account or API server. + if stats.cached_for_user.is_empty() || stats.cached_for_user != expected_user { + return None; + } + let cached_url = stats.cached_for_api_url.trim_end_matches('/'); + if cached_url.is_empty() || cached_url != expected_api_url.trim_end_matches('/') { + return None; + } + + Some(stats) +} + +fn save_remote_stats_cache(stats: &RemoteStats) -> Result<()> { + let cache_path = get_cache_path().context("Could not resolve remote stats cache directory")?; + let json = serde_json::to_string(stats).context("Failed to serialize remote stats cache")?; + let temp_path = cache_path.with_extension("json.tmp"); + fs::write(&temp_path, json).context("Failed to write remote stats temp cache file")?; + if tokscale_core::fs_atomic::replace_file(&temp_path, &cache_path).is_err() { + let _ = fs::remove_file(&temp_path); + anyhow::bail!("Failed to move remote stats cache into place"); + } + Ok(()) +} + +fn get_cache_path() -> Option { + let dir = crate::paths::get_cache_dir(); + if fs::create_dir_all(&dir).is_err() { + return None; + } + Some(dir.join(CACHE_FILE_NAME)) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::env; + + fn sample_stats(fetched_at_secs: u64) -> RemoteStats { + RemoteStats { + schema_version: SUPPORTED_SCHEMA_VERSION, + total_tokens: 1250, + total_cost: 1.75, + device_count: 2, + last_submitted_at: Some("2026-06-02T12:00:00.000Z".to_string()), + days: vec![RemoteDayStat { + date: "2026-06-01".to_string(), + tokens: 1000, + input_tokens: 600, + output_tokens: 400, + cost: 1.5, + }], + devices: vec![RemoteDeviceStat { + id: "device-1".to_string(), + display_name: Some("Work laptop".to_string()), + last_submitted_at: Some("2026-06-02T12:00:00.000Z".to_string()), + }], + fetched_at_secs, + cached_for_user: "alice".to_string(), + cached_for_api_url: "https://tokscale.ai".to_string(), + } + } + + fn with_temp_config_dir(test: impl FnOnce()) { + let temp = tempfile::tempdir().expect("tempdir"); + let prev = env::var_os("TOKSCALE_CONFIG_DIR"); + unsafe { + env::set_var("TOKSCALE_CONFIG_DIR", temp.path()); + } + test(); + unsafe { + match prev { + Some(v) => env::set_var("TOKSCALE_CONFIG_DIR", v), + None => env::remove_var("TOKSCALE_CONFIG_DIR"), + } + } + } + + #[test] + #[serial] + fn cache_round_trips_for_matching_account_and_server() { + with_temp_config_dir(|| { + let stats = sample_stats(now_secs()); + save_remote_stats_cache(&stats).expect("save cache"); + + let loaded = load_cached_remote_stats("alice", "https://tokscale.ai") + .expect("fresh cache should load"); + assert_eq!(loaded.total_tokens, 1250); + assert_eq!(loaded.device_count, 2); + assert_eq!(loaded.days.len(), 1); + assert_eq!( + loaded.devices[0].display_name.as_deref(), + Some("Work laptop") + ); + }); + } + + #[test] + #[serial] + fn cache_normalizes_trailing_slash_in_api_url() { + with_temp_config_dir(|| { + save_remote_stats_cache(&sample_stats(now_secs())).expect("save cache"); + assert!(load_cached_remote_stats("alice", "https://tokscale.ai/").is_some()); + }); + } + + #[test] + #[serial] + fn cache_rejects_stale_entries() { + with_temp_config_dir(|| { + let stats = sample_stats(now_secs().saturating_sub(CACHE_TTL_SECS + 1)); + save_remote_stats_cache(&stats).expect("save cache"); + assert!(load_cached_remote_stats("alice", "https://tokscale.ai").is_none()); + }); + } + + #[test] + #[serial] + fn cache_rejects_other_accounts_servers_and_anonymous_lookups() { + with_temp_config_dir(|| { + save_remote_stats_cache(&sample_stats(now_secs())).expect("save cache"); + assert!(load_cached_remote_stats("bob", "https://tokscale.ai").is_none()); + assert!(load_cached_remote_stats("alice", "https://staging.tokscale.ai").is_none()); + // Env-token sessions have no username; they must never trust cache. + assert!(load_cached_remote_stats("", "https://tokscale.ai").is_none()); + }); + } + + #[test] + #[serial] + fn cache_rejects_unknown_schema_versions() { + with_temp_config_dir(|| { + let mut stats = sample_stats(now_secs()); + stats.schema_version = SUPPORTED_SCHEMA_VERSION + 1; + save_remote_stats_cache(&stats).expect("save cache"); + assert!(load_cached_remote_stats("alice", "https://tokscale.ai").is_none()); + }); + } + + #[test] + fn staleness_follows_ttl() { + assert!(!sample_stats(now_secs()).is_stale()); + assert!(sample_stats(now_secs().saturating_sub(CACHE_TTL_SECS + 1)).is_stale()); + } +} diff --git a/crates/tokscale-cli/src/tui/settings.rs b/crates/tokscale-cli/src/tui/settings.rs index fcbe5e1c8..7089df92a 100644 --- a/crates/tokscale-cli/src/tui/settings.rs +++ b/crates/tokscale-cli/src/tui/settings.rs @@ -16,6 +16,10 @@ const DEFAULT_NATIVE_TIMEOUT_MS: u64 = 300_000; const MIN_NATIVE_TIMEOUT_MS: u64 = 5_000; const MAX_NATIVE_TIMEOUT_MS: u64 = 3_600_000; +pub const DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES: u64 = 24 * 60; +pub const MIN_AUTOSUBMIT_INTERVAL_MINUTES: u64 = 15; +pub const MAX_AUTOSUBMIT_INTERVAL_MINUTES: u64 = 7 * 24 * 60; + #[derive(Debug, Clone, Copy)] enum ExplicitHomeConfigLayout { UnixDotConfig, @@ -42,6 +46,67 @@ pub struct LightSettings { pub write_cache: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AutosubmitSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_autosubmit_interval_minutes")] + pub interval_minutes: u64, + #[serde(default, deserialize_with = "deserialize_string_array_lossy")] + pub clients: Vec, + #[serde(default)] + pub since: Option, + #[serde(default)] + pub until: Option, + #[serde(default)] + pub year: Option, + #[serde(default)] + pub today: bool, + #[serde(default)] + pub yesterday: bool, + #[serde(default)] + pub week: bool, + #[serde(default)] + pub month: bool, + #[serde(default)] + pub scheduler: Option, + #[serde(default)] + pub last_run_at_ms: Option, + #[serde(default)] + pub last_error: Option, +} + +impl Default for AutosubmitSettings { + fn default() -> Self { + Self { + enabled: false, + interval_minutes: DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES, + clients: Vec::new(), + since: None, + until: None, + year: None, + today: false, + yesterday: false, + week: false, + month: false, + scheduler: None, + last_run_at_ms: None, + last_error: None, + } + } +} + +impl AutosubmitSettings { + fn normalize(mut self) -> Self { + self.interval_minutes = self.interval_minutes.clamp( + MIN_AUTOSUBMIT_INTERVAL_MINUTES, + MAX_AUTOSUBMIT_INTERVAL_MINUTES, + ); + self + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Settings { @@ -84,6 +149,18 @@ pub struct Settings { /// tab and enable its aggregation in subsequent loads. #[serde(default)] pub minutely_tab_enabled: bool, + #[serde(default)] + pub autosubmit: AutosubmitSettings, + /// User-defined model-name aliases folded at grouping time. Different + /// name-strings for one physical model (e.g. `claude-opus-4-8-cc`, + /// `anthropic/claude-opus-4-8`) map to a single canonical name so usage + /// stats do not split across rows. Keys and values are matched + /// case-insensitively against the normalized model name. + /// + /// `#[serde(default)]` keeps settings.json files written before the field + /// existed loading cleanly; an absent or empty map means no folding. + #[serde(default)] + pub model_aliases: tokscale_core::ModelAliasMap, } /// Lossy deserializer for `defaultClients`: accepts an array of arbitrary @@ -117,6 +194,10 @@ fn default_native_timeout_ms() -> u64 { DEFAULT_NATIVE_TIMEOUT_MS } +fn default_autosubmit_interval_minutes() -> u64 { + DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES +} + impl Default for Settings { fn default() -> Self { Self { @@ -129,6 +210,8 @@ impl Default for Settings { default_clients: Vec::new(), light: LightSettings::default(), minutely_tab_enabled: false, + autosubmit: AutosubmitSettings::default(), + model_aliases: tokscale_core::ModelAliasMap::default(), } } } @@ -148,6 +231,13 @@ pub fn load_scanner_settings_for_home(home_dir: &Option) -> ScannerSetti Settings::load_for_home_override(home_dir.as_deref().map(Path::new)).scanner } +/// Loads the user's configured model aliases, honoring a `--home` override the +/// same way [`load_scanner_settings_for_home`] does. A missing or malformed +/// settings.json yields an empty map (no folding); this never errors. +pub fn load_model_aliases_for_home(home_dir: &Option) -> tokscale_core::ModelAliasMap { + Settings::load_for_home_override(home_dir.as_deref().map(Path::new)).model_aliases +} + /// Returns the user's configured `defaultClients` list as raw lowercase /// ids. Validation against the live `ClientFilter` enum happens at the /// CLI boundary so this module stays independent of the CLI types. @@ -170,6 +260,7 @@ impl Settings { self.native_timeout_ms = self .native_timeout_ms .clamp(MIN_NATIVE_TIMEOUT_MS, MAX_NATIVE_TIMEOUT_MS); + self.autosubmit = self.autosubmit.normalize(); self } @@ -468,6 +559,57 @@ mod tests { assert!(parsed.scanner.opencode_db_paths.is_empty()); } + #[test] + fn settings_load_backfills_autosubmit_interval_when_missing_from_json() { + let json = r#"{ + "colorPalette": "blue", + "autoRefreshEnabled": false, + "autoRefreshMs": 60000, + "includeUnusedModels": false, + "nativeTimeoutMs": 300000 + }"#; + let parsed: Settings = serde_json::from_str(json).unwrap(); + + assert!(!parsed.autosubmit.enabled); + assert_eq!( + parsed.autosubmit.interval_minutes, + DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES + ); + assert_eq!( + AutosubmitSettings::default().interval_minutes, + DEFAULT_AUTOSUBMIT_INTERVAL_MINUTES + ); + } + + #[test] + fn settings_backfills_model_aliases_when_missing_from_json() { + // Older settings.json files predate the `modelAliases` key; they must + // still deserialize cleanly and default to an empty (no-op) alias map. + let json = r#"{ + "colorPalette": "blue", + "autoRefreshEnabled": false, + "autoRefreshMs": 60000, + "includeUnusedModels": false, + "nativeTimeoutMs": 300000 + }"#; + let parsed: Settings = serde_json::from_str(json).unwrap(); + assert!(parsed.model_aliases.entries.is_empty()); + } + + #[test] + fn settings_malformed_model_aliases_does_not_wipe_other_fields() { + // A malformed `modelAliases` (not an object, or non-string values) must + // degrade to an empty map without failing the whole settings load, so + // unrelated settings survive. + let json = r#"{ + "colorPalette": "custom", + "modelAliases": ["oops", 5] + }"#; + let parsed: Settings = serde_json::from_str(json).unwrap(); + assert!(parsed.model_aliases.entries.is_empty()); + assert_eq!(parsed.color_palette, "custom"); + } + #[test] fn settings_load_reads_scanner_opencode_db_paths() { let json = r#"{ diff --git a/crates/tokscale-cli/src/tui/themes.rs b/crates/tokscale-cli/src/tui/themes.rs index f5f8775b8..838da44b9 100644 --- a/crates/tokscale-cli/src/tui/themes.rs +++ b/crates/tokscale-cli/src/tui/themes.rs @@ -1,4 +1,4 @@ -use ratatui::style::{Color, Style}; +use ratatui::style::{Color, Modifier, Style}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TerminalColorMode { @@ -56,6 +56,9 @@ pub enum ThemeName { Orange, Monochrome, YlGnBu, + Graphite, + Lagoon, + Dusk, } impl ThemeName { @@ -70,6 +73,9 @@ impl ThemeName { ThemeName::Orange, ThemeName::Monochrome, ThemeName::YlGnBu, + ThemeName::Graphite, + ThemeName::Lagoon, + ThemeName::Dusk, ] } @@ -90,6 +96,9 @@ impl ThemeName { ThemeName::Orange => "orange", ThemeName::Monochrome => "monochrome", ThemeName::YlGnBu => "ylgnbu", + ThemeName::Graphite => "graphite", + ThemeName::Lagoon => "lagoon", + ThemeName::Dusk => "dusk", } } } @@ -108,6 +117,9 @@ impl std::str::FromStr for ThemeName { "orange" => Ok(ThemeName::Orange), "monochrome" => Ok(ThemeName::Monochrome), "ylgnbu" => Ok(ThemeName::YlGnBu), + "graphite" => Ok(ThemeName::Graphite), + "lagoon" => Ok(ThemeName::Lagoon), + "dusk" => Ok(ThemeName::Dusk), _ => Err(()), } } @@ -124,6 +136,8 @@ pub struct Theme { pub muted: Color, pub accent: Color, pub selection: Color, + striped_row: Color, + current_row: Color, color_mode: TerminalColorMode, } @@ -201,6 +215,27 @@ impl Theme { Color::Rgb(44, 127, 184), // grade3: #2c7fb8 Color::Rgb(37, 52, 148), // grade4: #253494 ], + ThemeName::Graphite => [ + Color::Rgb(24, 27, 34), // grade0: empty + Color::Rgb(148, 163, 184), // grade1: #94a3b8 + Color::Rgb(125, 211, 252), // grade2: #7dd3fc + Color::Rgb(56, 189, 248), // grade3: #38bdf8 + Color::Rgb(14, 116, 144), // grade4: #0e7490 + ], + ThemeName::Lagoon => [ + Color::Rgb(6, 32, 36), // grade0: empty + Color::Rgb(153, 246, 228), // grade1: #99f6e4 + Color::Rgb(94, 234, 212), // grade2: #5eead4 + Color::Rgb(45, 212, 191), // grade3: #2dd4bf + Color::Rgb(15, 118, 110), // grade4: #0f766e + ], + ThemeName::Dusk => [ + Color::Rgb(27, 24, 38), // grade0: empty + Color::Rgb(196, 181, 253), // grade1: #c4b5fd + Color::Rgb(167, 139, 250), // grade2: #a78bfa + Color::Rgb(139, 92, 246), // grade3: #8b5cf6 + Color::Rgb(109, 40, 217), // grade4: #6d28d9 + ], }; let mut theme = Self { @@ -213,9 +248,45 @@ impl Theme { muted: Color::Rgb(139, 148, 158), accent: Color::Cyan, selection: Color::Rgb(48, 54, 61), + striped_row: Color::Rgb(20, 24, 30), + current_row: Color::Rgb(28, 42, 34), color_mode, }; + match name { + ThemeName::Graphite => { + theme.background = Color::Rgb(10, 12, 16); + theme.foreground = Color::Rgb(226, 232, 240); + theme.border = Color::Rgb(55, 65, 81); + theme.muted = Color::Rgb(148, 163, 184); + theme.accent = Color::Rgb(125, 211, 252); + theme.selection = Color::Rgb(31, 41, 55); + theme.striped_row = Color::Rgb(15, 18, 24); + theme.current_row = Color::Rgb(24, 39, 38); + } + ThemeName::Lagoon => { + theme.background = Color::Rgb(5, 20, 23); + theme.foreground = Color::Rgb(216, 241, 238); + theme.border = Color::Rgb(31, 83, 88); + theme.muted = Color::Rgb(133, 177, 175); + theme.accent = Color::Rgb(94, 234, 212); + theme.selection = Color::Rgb(15, 54, 58); + theme.striped_row = Color::Rgb(7, 26, 30); + theme.current_row = Color::Rgb(18, 54, 42); + } + ThemeName::Dusk => { + theme.background = Color::Rgb(17, 16, 26); + theme.foreground = Color::Rgb(232, 226, 238); + theme.border = Color::Rgb(63, 57, 82); + theme.muted = Color::Rgb(166, 154, 184); + theme.accent = Color::Rgb(196, 181, 253); + theme.selection = Color::Rgb(43, 37, 58); + theme.striped_row = Color::Rgb(22, 20, 32); + theme.current_row = Color::Rgb(40, 45, 36); + } + _ => {} + } + if color_mode == TerminalColorMode::Compatible { theme.colors = [ Color::Black, @@ -231,6 +302,8 @@ impl Theme { theme.muted = Color::DarkGray; theme.accent = Color::Cyan; theme.selection = Color::DarkGray; + theme.striped_row = Color::Black; + theme.current_row = Color::DarkGray; } theme @@ -259,6 +332,12 @@ impl Theme { Style::default().fg(self.color(Color::Rgb(200, 150, 100))) } + pub(crate) fn metric_total_style(&self) -> Style { + Style::default() + .fg(self.foreground) + .add_modifier(Modifier::BOLD) + } + pub(crate) fn secondary_text_style(&self) -> Style { Style::default().fg(self.color(Color::Rgb(170, 170, 170))) } @@ -271,7 +350,7 @@ impl Theme { if self.color_mode == TerminalColorMode::Compatible { Style::default() } else { - Style::default().bg(Color::Rgb(20, 24, 30)) + Style::default().bg(self.striped_row) } } @@ -279,7 +358,7 @@ impl Theme { if self.color_mode == TerminalColorMode::Compatible { Style::default().bg(self.selection) } else { - Style::default().bg(Color::Rgb(28, 42, 34)) + Style::default().bg(self.current_row) } } } @@ -363,6 +442,53 @@ mod tests { assert_eq!(mode, TerminalColorMode::Compatible); } + #[test] + fn theme_names_round_trip_through_settings_value() { + for theme in ThemeName::all() { + assert_eq!(theme.as_str().parse::(), Ok(*theme)); + } + } + + #[test] + fn surface_themes_customize_background_and_row_colors() { + let cases = [ + ( + ThemeName::Graphite, + Color::Rgb(10, 12, 16), + Color::Rgb(226, 232, 240), + Color::Rgb(31, 41, 55), + Color::Rgb(15, 18, 24), + Color::Rgb(24, 39, 38), + ), + ( + ThemeName::Lagoon, + Color::Rgb(5, 20, 23), + Color::Rgb(216, 241, 238), + Color::Rgb(15, 54, 58), + Color::Rgb(7, 26, 30), + Color::Rgb(18, 54, 42), + ), + ( + ThemeName::Dusk, + Color::Rgb(17, 16, 26), + Color::Rgb(232, 226, 238), + Color::Rgb(43, 37, 58), + Color::Rgb(22, 20, 32), + Color::Rgb(40, 45, 36), + ), + ]; + + for (name, background, foreground, selection, striped, current) in cases { + let theme = Theme::from_name_with_color_mode(name, TerminalColorMode::FullColor); + + assert_eq!(theme.background, background); + assert_eq!(theme.foreground, foreground); + assert_eq!(theme.selection, selection); + assert_eq!(theme.striped_row_style().bg, Some(striped)); + assert_eq!(theme.current_row_style().bg, Some(current)); + } + } + #[test] fn compatible_theme_preserves_name_and_avoids_rgb_palette() { let theme = @@ -400,6 +526,7 @@ mod tests { theme.metric_output_style(), theme.metric_cache_read_style(), theme.metric_cache_write_style(), + theme.metric_total_style(), theme.secondary_text_style(), theme.subtle_text_style(), theme.striped_row_style(), diff --git a/crates/tokscale-cli/src/tui/ui/agents.rs b/crates/tokscale-cli/src/tui/ui/agents.rs index d54a56fa0..3baa02a9c 100644 --- a/crates/tokscale-cli/src/tui/ui/agents.rs +++ b/crates/tokscale-cli/src/tui/ui/agents.rs @@ -1,9 +1,11 @@ use ratatui::prelude::*; use ratatui::widgets::{ - Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, }; -use super::widgets::{format_cost, format_tokens, get_client_display_name}; +use super::widgets::{ + format_cost, get_client_display_name, total_tokens_cell, viewport_scrollbar_state, +}; use crate::tui::app::{App, SortDirection, SortField}; use crate::ClientFilter; @@ -114,20 +116,20 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { vec![ Cell::from(truncate(&agent.agent, 18)) .style(Style::default().fg(app.theme.foreground)), - Cell::from(format_tokens(agent.tokens.total())), + total_tokens_cell(agent.tokens.total(), &app.theme), Cell::from(format_cost(agent.cost)).style(Style::default().fg(Color::Green)), ] } else { vec![ Cell::from(format!("{}", idx + 1)).style(Style::default().fg(theme_muted)), - Cell::from(truncate(&agent.agent, 22)).style( + Cell::from(truncate(&agent.agent, 32)).style( Style::default() .fg(app.theme.foreground) .add_modifier(Modifier::BOLD), ), Cell::from(truncate(&client_labels(&agent.clients), 24)) .style(Style::default().fg(theme_muted)), - Cell::from(format_tokens(agent.tokens.total())), + total_tokens_cell(agent.tokens.total(), &app.theme), Cell::from(format_cost(agent.cost)).style(Style::default().fg(Color::Green)), Cell::from(agent.message_count.to_string()) .style(Style::default().fg(theme_muted)), @@ -157,7 +159,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { } else { vec![ Constraint::Length(3), - Constraint::Min(16), + Constraint::Min(24), Constraint::Length(24), Constraint::Length(10), Constraint::Length(10), @@ -176,7 +178,8 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = ScrollbarState::new(agents_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(agents_len, scroll_offset, visible_height); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/daily.rs b/crates/tokscale-cli/src/tui/ui/daily.rs index 8c1850c08..a7a6b874c 100644 --- a/crates/tokscale-cli/src/tui/ui/daily.rs +++ b/crates/tokscale-cli/src/tui/ui/daily.rs @@ -1,12 +1,13 @@ use chrono::Local; use ratatui::prelude::*; use ratatui::widgets::{ - Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, }; use super::widgets::{ format_cache_hit_rate, format_cost, format_cost_per_million, format_tokens, - get_client_display_name, get_provider_display_name, + get_client_display_name, get_provider_display_name, total_tokens_cell, + viewport_scrollbar_state, }; use crate::tui::app::{App, SortDirection, SortField}; @@ -186,7 +187,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { } cells.extend([ Cell::from(day.message_count.to_string()), - Cell::from(format_tokens(day.tokens.total())), + total_tokens_cell(day.tokens.total(), &app.theme), Cell::from(format_cost(day.cost)).style(Style::default().fg(Color::Green)), ]); cells @@ -222,7 +223,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { day.tokens.cache_write, )) .style(Style::default().fg(Color::Cyan)), - Cell::from(format_tokens(day.tokens.total())), + total_tokens_cell(day.tokens.total(), &app.theme), Cell::from(format_cost(day.cost)).style(Style::default().fg(Color::Green)), Cell::from(format_cost_per_million(day.cost, day.tokens.total())) .style(Style::default().fg(Color::Rgb(150, 200, 150))), @@ -301,7 +302,8 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = ScrollbarState::new(daily_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(daily_len, scroll_offset, visible_height); frame.render_stateful_widget( scrollbar, @@ -444,7 +446,7 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { Cell::from(get_client_display_name(row.source)) .style(Style::default().fg(theme_muted)), Cell::from(row.messages.to_string()), - Cell::from(format_tokens(row.tokens.total())), + total_tokens_cell(row.tokens.total(), &app.theme), Cell::from(format_cost(row.cost)).style(Style::default().fg(Color::Green)), ] } else { @@ -470,7 +472,7 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { row.tokens.cache_write, )) .style(Style::default().fg(Color::Cyan)), - Cell::from(format_tokens(row.tokens.total())), + total_tokens_cell(row.tokens.total(), &app.theme), Cell::from(format_cost(row.cost)).style(Style::default().fg(Color::Green)), ] }; @@ -525,7 +527,8 @@ fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = ScrollbarState::new(detail_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(detail_len, scroll_offset, visible_height); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/dialog/confirm.rs b/crates/tokscale-cli/src/tui/ui/dialog/confirm.rs new file mode 100644 index 000000000..86ef06d4b --- /dev/null +++ b/crates/tokscale-cli/src/tui/ui/dialog/confirm.rs @@ -0,0 +1,365 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use crossterm::event::{KeyCode, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, Paragraph}; + +use crate::tui::themes::Theme; +use crate::tui::ui::widgets::truncate_ellipsis as truncate; + +use super::{DialogContent, DialogResult}; + +#[derive(Clone, Copy)] +enum ConfirmTone { + Accent, + Warning, + Danger, +} + +pub struct ConfirmDialog { + value: String, + title: &'static str, + message: &'static str, + target_label: String, + effect: &'static str, + confirm_label: &'static str, + confirm_verb: &'static str, + tone: ConfirmTone, + confirmed_value: Rc>>, +} + +struct ButtonLayout { + confirm: Option, + cancel: Option, +} + +impl ConfirmDialog { + pub fn codex_switch( + account_id: String, + account_label: String, + confirmed_value: Rc>>, + ) -> Self { + Self { + value: account_id, + title: " Switch Codex Account ", + message: "This will replace the active Codex auth.json account.", + target_label: account_label, + effect: "New Codex requests use this account", + confirm_label: "Confirm", + confirm_verb: "confirm", + tone: ConfirmTone::Accent, + confirmed_value, + } + } + + pub fn codex_remove( + account_id: String, + account_label: String, + confirmed_value: Rc>>, + ) -> Self { + Self { + value: account_id, + title: " Remove Codex Account ", + message: "This will remove the saved Codex account from Tokscale.", + target_label: account_label, + effect: "Saved account is deleted; codex CLI login is unchanged", + confirm_label: "Remove", + confirm_verb: "remove", + tone: ConfirmTone::Danger, + confirmed_value, + } + } + + pub fn codex_reset( + account_id: String, + account_label: String, + confirmed_value: Rc>>, + ) -> Self { + Self { + value: account_id, + title: " Reset Codex Limits ", + message: "This will consume one available Codex reset credit.", + target_label: account_label, + effect: "Codex rate-limit windows reset for this account", + confirm_label: "Reset", + confirm_verb: "reset", + tone: ConfirmTone::Warning, + confirmed_value, + } + } + + fn confirm(&self) { + *self.confirmed_value.borrow_mut() = Some(self.value.clone()); + } + + fn tone_color(&self, theme: &Theme) -> Color { + match self.tone { + ConfirmTone::Accent => theme.accent, + ConfirmTone::Warning => Color::Yellow, + ConfirmTone::Danger => Color::Red, + } + } + + fn confirm_button_style(&self, theme: &Theme) -> Style { + match self.tone { + ConfirmTone::Accent => Style::default().fg(theme.background).bg(theme.accent), + ConfirmTone::Warning => Style::default().fg(Color::Black).bg(Color::Yellow), + ConfirmTone::Danger => Style::default().fg(Color::Black).bg(Color::Red), + } + .add_modifier(Modifier::BOLD) + } + + fn content_area(area: Rect) -> Rect { + Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(2), + area.height.saturating_sub(2), + ) + } + + fn button_y(inner: Rect) -> Option { + match inner.height { + 0..=4 => None, + 5 => Some(inner.y.saturating_add(4)), + _ => Some(inner.y.saturating_add(inner.height.saturating_sub(2))), + } + } + + fn button_layout(&self, inner: Rect) -> ButtonLayout { + let Some(y) = Self::button_y(inner) else { + return ButtonLayout { + confirm: None, + cancel: None, + }; + }; + + let confirm_width = self.confirm_button().chars().count() as u16; + if inner.width < confirm_width { + return ButtonLayout { + confirm: None, + cancel: None, + }; + } + + let cancel_width = 10u16; + let gap = 2u16; + let total = confirm_width + .saturating_add(gap) + .saturating_add(cancel_width); + if inner.width >= total { + let x = inner.x + inner.width.saturating_sub(total) / 2; + return ButtonLayout { + confirm: Some(Rect::new(x, y, confirm_width, 1)), + cancel: Some(Rect::new(x + confirm_width + gap, y, cancel_width, 1)), + }; + } + + let x = inner.x + inner.width.saturating_sub(confirm_width) / 2; + ButtonLayout { + confirm: Some(Rect::new(x, y, confirm_width, 1)), + cancel: None, + } + } + + fn confirm_button(&self) -> String { + format!("[ {} ]", self.confirm_label) + } + + fn button_line( + &self, + layout: &ButtonLayout, + inner: Rect, + theme: &Theme, + ) -> Option> { + let confirm = layout.confirm?; + let mut spans = vec![ + Span::raw(" ".repeat(confirm.x.saturating_sub(inner.x) as usize)), + Span::styled(self.confirm_button(), self.confirm_button_style(theme)), + ]; + if let Some(cancel) = layout.cancel { + spans.push(Span::raw( + " ".repeat(cancel.x.saturating_sub(confirm.right()) as usize), + )); + spans.push(Span::styled("[ Cancel ]", Style::default().fg(theme.muted))); + } + Some(Line::from(spans)) + } + + fn contains(rect: Rect, column: u16, row: u16) -> bool { + column >= rect.x + && column < rect.x.saturating_add(rect.width) + && row >= rect.y + && row < rect.y.saturating_add(rect.height) + } +} + +impl DialogContent for ConfirmDialog { + fn desired_size(&self, viewport: Rect) -> (u16, u16) { + ( + 68u16.min(viewport.width.saturating_sub(4)), + 10u16.min(viewport.height.saturating_sub(4)), + ) + } + + fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let tone = self.tone_color(theme); + let block = Block::default() + .title(self.title) + .borders(Borders::ALL) + .border_style(Style::default().fg(tone)); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width == 0 || inner.height == 0 { + return; + } + + let mut lines = vec![ + Line::from(Span::styled( + self.message, + Style::default().fg(theme.foreground), + )), + Line::from(""), + labeled_line( + "Target", + &self.target_label, + Style::default().fg(tone).add_modifier(Modifier::BOLD), + inner.width, + theme, + ), + labeled_line( + "Effect", + self.effect, + theme.secondary_text_style(), + inner.width, + theme, + ), + ]; + + let layout = self.button_layout(inner); + if let Some(button_line) = self.button_line(&layout, inner, theme) { + let button_row = layout + .confirm + .map(|rect| rect.y.saturating_sub(inner.y) as usize) + .unwrap_or(lines.len()); + while lines.len() < button_row { + lines.push(Line::from("")); + } + lines.push(button_line); + } + + let hint = Line::from(Span::styled( + format!("Enter/y {} - n/Esc cancel", self.confirm_verb), + Style::default().fg(theme.muted), + )) + .centered(); + if lines.len() < inner.height as usize { + lines.push(hint); + } + + frame.render_widget(Paragraph::new(lines), inner); + } + + fn handle_key(&mut self, key: KeyCode) -> DialogResult { + // y/n are commands, not text, so remap them for non-Latin layouts. + let key = crate::tui::keymap::normalize_hotkey(key); + match key { + KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => { + self.confirm(); + DialogResult::Close + } + KeyCode::Char('n') | KeyCode::Char('N') => DialogResult::Close, + _ => DialogResult::None, + } + } + + fn handle_mouse(&mut self, event: MouseEvent, area: Rect) -> DialogResult { + if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + return DialogResult::None; + } + + let layout = self.button_layout(Self::content_area(area)); + if layout + .confirm + .is_some_and(|rect| Self::contains(rect, event.column, event.row)) + { + self.confirm(); + DialogResult::Close + } else if layout + .cancel + .is_some_and(|rect| Self::contains(rect, event.column, event.row)) + { + DialogResult::Close + } else { + DialogResult::None + } + } +} + +fn labeled_line( + label: &'static str, + value: &str, + value_style: Style, + width: u16, + theme: &Theme, +) -> Line<'static> { + let width = width as usize; + let label_text = format!("{label:<8}"); + let label_width = label_text.chars().count(); + if width <= label_width { + return Line::from(Span::styled( + truncate(&label_text, width), + Style::default().fg(theme.muted), + )); + } + + Line::from(vec![ + Span::styled(label_text, Style::default().fg(theme.muted)), + Span::styled(truncate(value, width - label_width), value_style), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::themes::ThemeName; + use ratatui::{backend::TestBackend, Terminal}; + + fn render_dialog(width: u16, height: u16) -> String { + let confirmed = Rc::new(RefCell::new(None)); + let dialog = ConfirmDialog::codex_switch( + "acct_123".to_string(), + "very-long-account-label".to_string(), + confirmed, + ); + let theme = Theme::from_name_for_current_terminal(ThemeName::Blue); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| dialog.render(frame, Rect::new(0, 0, width, height), &theme)) + .unwrap(); + + terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| { + row.iter() + .map(|cell| cell.symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn narrow_confirm_dialog_does_not_render_orphan_ellipsis_for_zero_target_width() { + let body = render_dialog(10, 8); + + assert!(body.contains("Target"), "{body}"); + assert!(!body.contains("..."), "{body}"); + } +} diff --git a/crates/tokscale-cli/src/tui/ui/dialog/mod.rs b/crates/tokscale-cli/src/tui/ui/dialog/mod.rs index 593bc30e7..ef12affa0 100644 --- a/crates/tokscale-cli/src/tui/ui/dialog/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/dialog/mod.rs @@ -1,3 +1,4 @@ +pub mod confirm; pub mod group_by_picker; pub mod overlay; pub mod source_picker; @@ -8,6 +9,7 @@ use ratatui::{layout::Rect, Frame}; use crate::tui::themes::Theme; +pub use confirm::ConfirmDialog; pub use group_by_picker::GroupByPickerDialog; pub use source_picker::ClientPickerDialog; pub use stack::DialogStack; diff --git a/crates/tokscale-cli/src/tui/ui/footer.rs b/crates/tokscale-cli/src/tui/ui/footer.rs index df2b8b0e9..7c8b8d65b 100644 --- a/crates/tokscale-cli/src/tui/ui/footer.rs +++ b/crates/tokscale-cli/src/tui/ui/footer.rs @@ -158,6 +158,10 @@ fn current_count_label(app: &App) -> String { Tab::Daily => format!(" ({} days)", app.data.daily.len()), Tab::Hourly => format!(" ({} hours)", app.data.hourly.len()), Tab::Minutely => format!(" ({} minutes)", app.data.minutely.len()), + Tab::Monthly if app.is_monthly_detail_active() => { + format!(" ({} days)", app.get_sorted_monthly_detail_days().len()) + } + Tab::Monthly => format!(" ({} months)", app.data.monthly.len()), Tab::Stats | Tab::Usage => String::new(), } } @@ -193,6 +197,14 @@ fn render_help_row(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled("j", Style::default().fg(Color::Yellow))); } } + if app.current_tab == Tab::Monthly { + spans.push(Span::styled("·", Style::default().fg(app.theme.muted))); + if app.is_monthly_detail_active() { + spans.push(Span::styled("esc", Style::default().fg(Color::Yellow))); + } else { + spans.push(Span::styled("↵", Style::default().fg(Color::Yellow))); + } + } if app.current_tab == Tab::Hourly { spans.push(Span::styled("·", Style::default().fg(app.theme.muted))); spans.push(Span::styled("v", Style::default().fg(Color::Yellow))); @@ -226,6 +238,20 @@ fn render_help_row(frame: &mut Frame, app: &App, area: Rect) { } spans.push(Span::styled(" • ", Style::default().fg(app.theme.muted))); } + if app.current_tab == Tab::Monthly { + if app.is_monthly_detail_active() { + spans.push(Span::styled( + "[esc:back]", + Style::default().fg(Color::Yellow), + )); + } else { + spans.push(Span::styled( + "[enter:details]", + Style::default().fg(Color::Yellow), + )); + } + spans.push(Span::styled(" • ", Style::default().fg(app.theme.muted))); + } if app.current_tab == Tab::Hourly { spans.push(Span::styled( "[v:profile]", @@ -277,9 +303,46 @@ fn render_help_row(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(paragraph, area); } +/// Data-source indicator label (#699): "local" when only this machine's +/// data is on screen, or "local+remote (N devices)" when server-side +/// aggregated stats are available for cross-checking. +fn data_source_label(app: &App) -> String { + match app.remote_stats { + Some(ref remote) => { + let devices = if remote.device_count == 1 { + "1 device".to_string() + } else { + format!("{} devices", remote.device_count) + }; + format!("local+remote ({})", devices) + } + None => "local".to_string(), + } +} + fn render_status_row(frame: &mut Frame, app: &App, area: Rect) { let mut spans: Vec = Vec::new(); + // Always-visible data-source indicator, so it is clear whether the + // numbers on screen are local-only or backed by server-side aggregates. + spans.push(Span::styled( + data_source_label(app), + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )); + if let Some(ref remote) = app.remote_stats { + spans.push(Span::styled( + format!( + " all devices: {} · {}", + format_tokens(remote.total_tokens), + format_cost(remote.total_cost) + ), + Style::default().fg(app.theme.muted), + )); + } + spans.push(Span::styled(" • ", Style::default().fg(app.theme.muted))); + if app.data.loading { let scanner_spans = get_scanner_spans(app.spinner_frame, &app.theme); spans.extend(scanner_spans); @@ -369,6 +432,10 @@ mod tests { ); assert_eq!(current_count_label(&make_app_on(Tab::Daily)), " (0 days)"); assert_eq!(current_count_label(&make_app_on(Tab::Hourly)), " (0 hours)"); + assert_eq!( + current_count_label(&make_app_on(Tab::Monthly)), + " (0 months)" + ); assert_eq!(current_count_label(&make_app_on(Tab::Stats)), ""); } @@ -379,4 +446,31 @@ mod tests { app.current_tab = Tab::Minutely; assert_eq!(current_count_label(&app), " (0 minutes)"); } + + #[test] + fn test_data_source_label_local_without_remote_stats() { + let app = make_app_on(Tab::Models); + assert_eq!(data_source_label(&app), "local"); + } + + #[test] + fn test_data_source_label_with_remote_stats() { + let mut app = make_app_on(Tab::Models); + app.remote_stats = Some(crate::tui::remote::RemoteStats { + schema_version: 1, + total_tokens: 1250, + total_cost: 1.75, + device_count: 2, + last_submitted_at: None, + days: Vec::new(), + devices: Vec::new(), + fetched_at_secs: 0, + cached_for_user: "alice".to_string(), + cached_for_api_url: "https://tokscale.ai".to_string(), + }); + assert_eq!(data_source_label(&app), "local+remote (2 devices)"); + + app.remote_stats.as_mut().unwrap().device_count = 1; + assert_eq!(data_source_label(&app), "local+remote (1 device)"); + } } diff --git a/crates/tokscale-cli/src/tui/ui/hourly.rs b/crates/tokscale-cli/src/tui/ui/hourly.rs index f611de47e..c1d27ae56 100644 --- a/crates/tokscale-cli/src/tui/ui/hourly.rs +++ b/crates/tokscale-cli/src/tui/ui/hourly.rs @@ -1,11 +1,14 @@ use chrono::{Local, NaiveDate, Timelike}; use ratatui::prelude::*; use ratatui::widgets::{ - Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, }; use super::hourly_profile; -use super::widgets::{format_cache_hit_rate, format_cost, format_cost_per_million, format_tokens}; +use super::widgets::{ + format_cache_hit_rate, format_cost, format_cost_per_million, format_tokens, total_tokens_cell, + viewport_scrollbar_state, +}; use crate::tui::app::{App, HourlyViewMode, SortDirection, SortField}; pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { @@ -222,7 +225,7 @@ fn render_table(frame: &mut Frame, app: &mut App, area: Rect) { } cells.extend([ Cell::from(hour.message_count.to_string()), - Cell::from(format_tokens(hour.tokens.total())), + total_tokens_cell(hour.tokens.total(), &app.theme), Cell::from(format_cost(hour.cost)).style(Style::default().fg(Color::Green)), ]); cells @@ -246,7 +249,7 @@ fn render_table(frame: &mut Frame, app: &mut App, area: Rect) { hour.tokens.cache_write, )) .style(Style::default().fg(Color::Cyan)), - Cell::from(format_tokens(hour.tokens.total())), + total_tokens_cell(hour.tokens.total(), &app.theme), Cell::from(format_cost(hour.cost)).style(Style::default().fg(Color::Green)), Cell::from(format_cost_per_million(hour.cost, hour.tokens.total())) .style(Style::default().fg(Color::Rgb(150, 200, 150))), @@ -336,7 +339,8 @@ fn render_table(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = ScrollbarState::new(hourly_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(hourly_len, scroll_offset, data_rows_shown); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/minutely.rs b/crates/tokscale-cli/src/tui/ui/minutely.rs index 4dd5529ce..dfc074a18 100644 --- a/crates/tokscale-cli/src/tui/ui/minutely.rs +++ b/crates/tokscale-cli/src/tui/ui/minutely.rs @@ -1,10 +1,12 @@ use chrono::{Local, Timelike}; use ratatui::prelude::*; use ratatui::widgets::{ - Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, }; -use super::widgets::{format_cache_hit_rate, format_cost, format_tokens}; +use super::widgets::{ + format_cache_hit_rate, format_cost, format_tokens, total_tokens_cell, viewport_scrollbar_state, +}; use crate::tui::app::{App, SortDirection, SortField}; pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { @@ -174,7 +176,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { } cells.extend([ Cell::from(minute.message_count.to_string()), - Cell::from(format_tokens(minute.tokens.total())), + total_tokens_cell(minute.tokens.total(), &app.theme), Cell::from(format_cost(minute.cost)).style(Style::default().fg(Color::Green)), ]); cells @@ -213,7 +215,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { minute.tokens.cache_write, )) .style(Style::default().fg(Color::Cyan)), - Cell::from(format_tokens(minute.tokens.total())), + total_tokens_cell(minute.tokens.total(), &app.theme), Cell::from(format_cost(minute.cost)).style(Style::default().fg(Color::Green)), ]); cells @@ -292,7 +294,8 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = ScrollbarState::new(minutely_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(minutely_len, scroll_offset, visible_height); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/mod.rs b/crates/tokscale-cli/src/tui/ui/mod.rs index 7e59a77ec..a619c9ec5 100644 --- a/crates/tokscale-cli/src/tui/ui/mod.rs +++ b/crates/tokscale-cli/src/tui/ui/mod.rs @@ -8,6 +8,7 @@ mod hourly; mod hourly_profile; mod minutely; mod models; +mod monthly; mod overview; pub mod spinner; mod stats; @@ -51,6 +52,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { Tab::Daily => daily::render(frame, app, chunks[1]), Tab::Hourly => hourly::render(frame, app, chunks[1]), Tab::Minutely => minutely::render(frame, app, chunks[1]), + Tab::Monthly => monthly::render(frame, app, chunks[1]), Tab::Stats => stats::render(frame, app, chunks[1]), Tab::Usage => usage::render(frame, app, chunks[1]), } diff --git a/crates/tokscale-cli/src/tui/ui/models.rs b/crates/tokscale-cli/src/tui/ui/models.rs index 3a6b0eac1..6bddd5dcf 100644 --- a/crates/tokscale-cli/src/tui/ui/models.rs +++ b/crates/tokscale-cli/src/tui/ui/models.rs @@ -1,11 +1,12 @@ use ratatui::prelude::*; use ratatui::widgets::{ - Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, }; use super::widgets::{ format_cache_hit_rate, format_cost, format_cost_per_million, format_ms_per_1k, format_tokens, - get_client_display_name, get_provider_display_name, + get_client_display_name, get_provider_display_name, total_tokens_cell, + viewport_scrollbar_state, }; use crate::tui::app::{App, SortDirection, SortField}; use tokscale_core::GroupBy; @@ -159,7 +160,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { } else if is_narrow { vec![ Cell::from(truncate(&display_name, 25)).style(Style::default().fg(model_color)), - Cell::from(format_tokens(model.tokens.total())), + total_tokens_cell(model.tokens.total(), &app.theme), Cell::from(format_cost(model.cost)).style(Style::default().fg(Color::Green)), ] } else if group_by == GroupBy::WorkspaceModel { @@ -184,7 +185,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .style(metric_cache_read_style), Cell::from(format_tokens(model.tokens.cache_write)) .style(metric_cache_write_style), - Cell::from(format_tokens(model.tokens.total())), + total_tokens_cell(model.tokens.total(), &app.theme), Cell::from(format_ms_per_1k(model.performance.ms_per_1k_tokens)) .style(Style::default().fg(Color::Yellow)), Cell::from(format_cost(model.cost)).style(Style::default().fg(Color::Green)), @@ -214,7 +215,7 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { model.tokens.cache_write, )) .style(Style::default().fg(Color::Cyan)), - Cell::from(format_tokens(model.tokens.total())), + total_tokens_cell(model.tokens.total(), &app.theme), Cell::from(format_ms_per_1k(model.performance.ms_per_1k_tokens)) .style(Style::default().fg(Color::Yellow)), Cell::from(format_cost(model.cost)).style(Style::default().fg(Color::Green)), @@ -288,7 +289,8 @@ pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = ScrollbarState::new(models_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(models_len, scroll_offset, visible_height); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/monthly.rs b/crates/tokscale-cli/src/tui/ui/monthly.rs new file mode 100644 index 000000000..e9525bc46 --- /dev/null +++ b/crates/tokscale-cli/src/tui/ui/monthly.rs @@ -0,0 +1,658 @@ +use ratatui::prelude::*; +use ratatui::widgets::{ + Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, Table, +}; + +use super::widgets::{ + format_cache_hit_rate, format_cost, format_cost_per_million, format_tokens, total_tokens_cell, + viewport_scrollbar_state, +}; +use crate::tui::app::{App, SortDirection, SortField}; + +pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { + if app.is_monthly_detail_active() { + render_detail(frame, app, area); + return; + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + " Monthly Usage ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.background)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let visible_height = inner.height.saturating_sub(1) as usize; + app.set_max_visible_items(visible_height); + + let monthly = app.get_sorted_monthly(); + if monthly.is_empty() { + let empty_msg = Paragraph::new("No monthly usage data found. Press 'r' to refresh.") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(empty_msg, inner); + return; + } + + let is_narrow = app.is_narrow(); + let is_very_narrow = app.is_very_narrow(); + let has_turn_data = monthly.iter().any(|m| m.turn_count > 0); + let sort_field = app.sort_field; + let sort_direction = app.sort_direction; + let scroll_offset = app.scroll_offset; + let selected_index = app.selected_index; + let theme_accent = app.theme.accent; + let theme_selection = app.theme.selection; + let metric_input_style = app.theme.metric_input_style(); + let metric_output_style = app.theme.metric_output_style(); + let metric_cache_read_style = app.theme.metric_cache_read_style(); + let metric_cache_write_style = app.theme.metric_cache_write_style(); + let striped_row_style = app.theme.striped_row_style(); + + let full_layout_width: u16 = if has_turn_data { 112 } else { 105 }; + let compact_full_date = !is_narrow && !is_very_narrow && inner.width < full_layout_width; + let month_col_width: u16 = if compact_full_date { 7 } else { 12 }; + + let header_cells = if is_very_narrow { + vec!["Month", "Cost"] + } else if is_narrow { + if has_turn_data { + vec!["Month", "Turn", "Msgs", "Tokens", "Cost"] + } else { + vec!["Month", "Msgs", "Tokens", "Cost"] + } + } else if has_turn_data { + vec![ + "Month", "Turn", "Msgs", "Input", "Output", "Cache R", "Cache W", "Cache×", "Total", + "Cost", "Cost/1M", + ] + } else { + vec![ + "Month", "Msgs", "Input", "Output", "Cache R", "Cache W", "Cache×", "Total", "Cost", + "Cost/1M", + ] + }; + + let sort_indicator = |field: SortField| -> &'static str { + if sort_field == field { + match sort_direction { + SortDirection::Ascending => " ▲", + SortDirection::Descending => " ▼", + } + } else { + "" + } + }; + + let header = Row::new( + header_cells + .iter() + .enumerate() + .map(|(i, h)| { + let indicator = match (i, is_narrow, is_very_narrow) { + (0, _, _) => sort_indicator(SortField::Date), + (8, false, false) if has_turn_data => sort_indicator(SortField::Tokens), + (7, false, false) if !has_turn_data => sort_indicator(SortField::Tokens), + (3, true, false) if has_turn_data => sort_indicator(SortField::Tokens), + (2, true, false) if !has_turn_data => sort_indicator(SortField::Tokens), + (9, false, false) if has_turn_data => sort_indicator(SortField::Cost), + (8, false, false) if !has_turn_data => sort_indicator(SortField::Cost), + (4, true, false) if has_turn_data => sort_indicator(SortField::Cost), + (3, true, false) if !has_turn_data => sort_indicator(SortField::Cost), + (1, _, true) => sort_indicator(SortField::Cost), + _ => "", + }; + Cell::from(format!("{}{}", h, indicator)) + }) + .collect::>(), + ) + .style( + Style::default() + .fg(theme_accent) + .add_modifier(Modifier::BOLD), + ) + .height(1); + + let monthly_len = monthly.len(); + let start = scroll_offset.min(monthly_len); + let end = (start + visible_height).min(monthly_len); + + if start >= monthly_len { + return; + } + + let rows: Vec = monthly[start..end] + .iter() + .enumerate() + .map(|(i, month)| { + let idx = i + start; + let is_selected = idx == selected_index; + let is_striped = idx % 2 == 1; + + let cells: Vec = if is_very_narrow { + vec![ + Cell::from(month.month.clone()), + Cell::from(format_cost(month.cost)).style(Style::default().fg(Color::Green)), + ] + } else if is_narrow { + let mut cells = vec![Cell::from(month.month.clone())]; + if has_turn_data { + let turn_str = if month.turn_count > 0 { + month.turn_count.to_string() + } else { + "\u{2014}".to_string() + }; + cells.push(Cell::from(turn_str)); + } + cells.extend([ + Cell::from(month.message_count.to_string()), + total_tokens_cell(month.tokens.total(), &app.theme), + Cell::from(format_cost(month.cost)).style(Style::default().fg(Color::Green)), + ]); + cells + } else { + let mut cells = vec![Cell::from(month.month.clone())]; + if has_turn_data { + let turn_str = if month.turn_count > 0 { + month.turn_count.to_string() + } else { + "\u{2014}".to_string() + }; + cells.push(Cell::from(turn_str)); + } + cells.extend([ + Cell::from(month.message_count.to_string()), + Cell::from(format_tokens(month.tokens.input)).style(metric_input_style), + Cell::from(format_tokens(month.tokens.output)).style(metric_output_style), + Cell::from(format_tokens(month.tokens.cache_read)) + .style(metric_cache_read_style), + Cell::from(format_tokens(month.tokens.cache_write)) + .style(metric_cache_write_style), + Cell::from(format_cache_hit_rate( + month.tokens.cache_read, + month.tokens.input, + month.tokens.cache_write, + )) + .style(Style::default().fg(Color::Cyan)), + total_tokens_cell(month.tokens.total(), &app.theme), + Cell::from(format_cost(month.cost)).style(Style::default().fg(Color::Green)), + Cell::from(format_cost_per_million(month.cost, month.tokens.total())) + .style(Style::default().fg(Color::Rgb(150, 200, 150))), + ]); + cells + }; + + let row_style = if is_selected { + Style::default().bg(theme_selection) + } else if is_striped { + striped_row_style + } else { + Style::default() + }; + + Row::new(cells).style(row_style).height(1) + }) + .collect(); + + let widths = if is_very_narrow { + vec![Constraint::Percentage(60), Constraint::Percentage(40)] + } else if is_narrow && has_turn_data { + vec![ + Constraint::Percentage(30), + Constraint::Percentage(15), + Constraint::Percentage(15), + Constraint::Percentage(20), + Constraint::Percentage(20), + ] + } else if is_narrow { + vec![ + Constraint::Percentage(35), + Constraint::Percentage(20), + Constraint::Percentage(25), + Constraint::Percentage(20), + ] + } else if has_turn_data { + vec![ + Constraint::Length(month_col_width), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + ] + } else { + vec![ + Constraint::Length(month_col_width), + Constraint::Length(6), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + ] + }; + + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(theme_selection)); + + frame.render_widget(table, inner); + + if monthly_len > visible_height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = + viewport_scrollbar_state(monthly_len, scroll_offset, visible_height); + + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + horizontal: 0, + vertical: 1, + }), + &mut scrollbar_state, + ); + } +} + +fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { + let title = app + .monthly_detail_month() + .map(|month| format!(" Daily Breakdown: {} ", month)) + .unwrap_or_else(|| " Daily Breakdown ".to_string()); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + title, + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(app.theme.background)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let visible_height = inner.height.saturating_sub(1) as usize; + app.set_max_visible_items(visible_height); + + let days = app.get_sorted_monthly_detail_days(); + if days.is_empty() { + let empty_msg = Paragraph::new("No daily data found for this month. Press Esc to go back.") + .style(Style::default().fg(app.theme.muted)) + .alignment(Alignment::Center); + frame.render_widget(empty_msg, inner); + return; + } + + let is_narrow = app.is_narrow(); + let is_very_narrow = app.is_very_narrow(); + let has_turn_data = days.iter().any(|d| d.turn_count > 0); + let sort_field = app.sort_field; + let sort_direction = app.sort_direction; + let scroll_offset = app.scroll_offset; + let selected_index = app.selected_index; + let theme_accent = app.theme.accent; + let theme_selection = app.theme.selection; + let metric_input_style = app.theme.metric_input_style(); + let metric_output_style = app.theme.metric_output_style(); + let metric_cache_read_style = app.theme.metric_cache_read_style(); + let metric_cache_write_style = app.theme.metric_cache_write_style(); + let striped_row_style = app.theme.striped_row_style(); + + let full_layout_width: u16 = if has_turn_data { 112 } else { 105 }; + let compact_full_date = !is_narrow && !is_very_narrow && inner.width < full_layout_width; + let date_col_width: u16 = if compact_full_date { 7 } else { 12 }; + let date_fmt: &str = if is_very_narrow { + "%m/%d" + } else if is_narrow || compact_full_date { + "%m-%d" + } else { + "%Y-%m-%d" + }; + + let header_cells = if is_very_narrow { + vec!["Date", "Cost"] + } else if is_narrow { + if has_turn_data { + vec!["Date", "Turn", "Msgs", "Tokens", "Cost"] + } else { + vec!["Date", "Msgs", "Tokens", "Cost"] + } + } else if has_turn_data { + vec![ + "Date", "Turn", "Msgs", "Input", "Output", "Cache R", "Cache W", "Cache×", "Total", + "Cost", "Cost/1M", + ] + } else { + vec![ + "Date", "Msgs", "Input", "Output", "Cache R", "Cache W", "Cache×", "Total", "Cost", + "Cost/1M", + ] + }; + + let sort_indicator = |field: SortField| -> &'static str { + if sort_field == field { + match sort_direction { + SortDirection::Ascending => " ▲", + SortDirection::Descending => " ▼", + } + } else { + "" + } + }; + + let header = Row::new( + header_cells + .iter() + .enumerate() + .map(|(i, h)| { + let indicator = match (i, is_narrow, is_very_narrow) { + (0, _, _) => sort_indicator(SortField::Date), + (8, false, false) if has_turn_data => sort_indicator(SortField::Tokens), + (7, false, false) if !has_turn_data => sort_indicator(SortField::Tokens), + (3, true, false) if has_turn_data => sort_indicator(SortField::Tokens), + (2, true, false) if !has_turn_data => sort_indicator(SortField::Tokens), + (9, false, false) if has_turn_data => sort_indicator(SortField::Cost), + (8, false, false) if !has_turn_data => sort_indicator(SortField::Cost), + (4, true, false) if has_turn_data => sort_indicator(SortField::Cost), + (3, true, false) if !has_turn_data => sort_indicator(SortField::Cost), + (1, _, true) => sort_indicator(SortField::Cost), + _ => "", + }; + Cell::from(format!("{}{}", h, indicator)) + }) + .collect::>(), + ) + .style( + Style::default() + .fg(theme_accent) + .add_modifier(Modifier::BOLD), + ) + .height(1); + + let days_len = days.len(); + let start = scroll_offset.min(days_len); + let end = (start + visible_height).min(days_len); + + if start >= days_len { + return; + } + + let rows: Vec = days[start..end] + .iter() + .enumerate() + .map(|(i, day)| { + let idx = i + start; + let is_selected = idx == selected_index; + let is_striped = idx % 2 == 1; + + let cells: Vec = if is_very_narrow { + vec![ + Cell::from(day.date.format(date_fmt).to_string()), + Cell::from(format_cost(day.cost)).style(Style::default().fg(Color::Green)), + ] + } else if is_narrow { + let mut cells = vec![Cell::from(day.date.format(date_fmt).to_string())]; + if has_turn_data { + let turn_str = if day.turn_count > 0 { + day.turn_count.to_string() + } else { + "\u{2014}".to_string() + }; + cells.push(Cell::from(turn_str)); + } + cells.extend([ + Cell::from(day.message_count.to_string()), + total_tokens_cell(day.tokens.total(), &app.theme), + Cell::from(format_cost(day.cost)).style(Style::default().fg(Color::Green)), + ]); + cells + } else { + let mut cells = vec![Cell::from(day.date.format(date_fmt).to_string())]; + if has_turn_data { + let turn_str = if day.turn_count > 0 { + day.turn_count.to_string() + } else { + "\u{2014}".to_string() + }; + cells.push(Cell::from(turn_str)); + } + cells.extend([ + Cell::from(day.message_count.to_string()), + Cell::from(format_tokens(day.tokens.input)).style(metric_input_style), + Cell::from(format_tokens(day.tokens.output)).style(metric_output_style), + Cell::from(format_tokens(day.tokens.cache_read)).style(metric_cache_read_style), + Cell::from(format_tokens(day.tokens.cache_write)) + .style(metric_cache_write_style), + Cell::from(format_cache_hit_rate( + day.tokens.cache_read, + day.tokens.input, + day.tokens.cache_write, + )) + .style(Style::default().fg(Color::Cyan)), + total_tokens_cell(day.tokens.total(), &app.theme), + Cell::from(format_cost(day.cost)).style(Style::default().fg(Color::Green)), + Cell::from(format_cost_per_million(day.cost, day.tokens.total())) + .style(Style::default().fg(Color::Rgb(150, 200, 150))), + ]); + cells + }; + + let row_style = if is_selected { + Style::default().bg(theme_selection) + } else if is_striped { + striped_row_style + } else { + Style::default() + }; + + Row::new(cells).style(row_style).height(1) + }) + .collect(); + + let widths = if is_very_narrow { + vec![Constraint::Percentage(60), Constraint::Percentage(40)] + } else if is_narrow && has_turn_data { + vec![ + Constraint::Percentage(30), + Constraint::Percentage(15), + Constraint::Percentage(15), + Constraint::Percentage(20), + Constraint::Percentage(20), + ] + } else if is_narrow { + vec![ + Constraint::Percentage(35), + Constraint::Percentage(20), + Constraint::Percentage(25), + Constraint::Percentage(20), + ] + } else if has_turn_data { + vec![ + Constraint::Length(date_col_width), + Constraint::Length(6), + Constraint::Length(6), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + ] + } else { + vec![ + Constraint::Length(date_col_width), + Constraint::Length(6), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(10), + Constraint::Length(10), + ] + }; + + let table = Table::new(rows, widths) + .header(header) + .row_highlight_style(Style::default().bg(theme_selection)); + + frame.render_widget(table, inner); + + if days_len > visible_height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("▲")) + .end_symbol(Some("▼")); + + let mut scrollbar_state = viewport_scrollbar_state(days_len, scroll_offset, visible_height); + + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + horizontal: 0, + vertical: 1, + }), + &mut scrollbar_state, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::{Tab, TuiConfig}; + use crate::tui::data::{DailyUsage, MonthlyUsage, TokenBreakdown}; + use chrono::NaiveDate; + use ratatui::{backend::TestBackend, Terminal}; + use std::collections::BTreeMap; + + fn month(month: &str, input: u64, cost: f64) -> MonthlyUsage { + MonthlyUsage { + month: month.to_string(), + tokens: TokenBreakdown { + input, + output: 0, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }, + cost, + message_count: 1, + turn_count: 0, + } + } + + fn day(date: &str, input: u64, cost: f64) -> DailyUsage { + DailyUsage { + date: NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap(), + tokens: TokenBreakdown { + input, + output: 0, + cache_read: 0, + cache_write: 0, + reasoning: 0, + }, + cost, + source_breakdown: BTreeMap::new(), + message_count: 1, + turn_count: 0, + } + } + + fn make_app(width: u16) -> App { + let config = TuiConfig { + theme: "blue".to_string(), + refresh: 0, + sessions_path: None, + clients: None, + since: None, + until: None, + year: None, + initial_tab: None, + }; + let mut app = App::new_with_cached_data(config, None).unwrap(); + app.terminal_width = width; + app.current_tab = Tab::Monthly; + app.sort_field = SortField::Date; + app.sort_direction = SortDirection::Descending; + app + } + + fn render_body(app: &mut App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render(frame, app, Rect::new(0, 0, width, height))) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| { + row.iter() + .map(|c| c.symbol().to_string()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn wide_terminal_renders_full_monthly_columns() { + let mut app = make_app(130); + app.data.monthly = vec![month("2026-05", 1000, 1.5)]; + let body = render_body(&mut app, 130, 12); + assert!( + body.contains("Cache×"), + "expected cache hit rate column\n{body}" + ); + assert!( + body.contains("Cost/1M"), + "expected cost per million column\n{body}" + ); + assert!(body.contains("2026-05"), "expected month row\n{body}"); + } + + #[test] + fn monthly_detail_renders_daily_breakdown_title() { + let mut app = make_app(130); + app.data.monthly = vec![month("2026-05", 1000, 1.5)]; + app.data.daily = vec![day("2026-05-10", 500, 0.75), day("2026-04-05", 200, 0.25)]; + app.selected_monthly_detail_month = Some("2026-05".to_string()); + + let body = render_body(&mut app, 130, 12); + assert!( + body.contains("Daily Breakdown: 2026-05"), + "expected detail title\n{body}" + ); + assert!(body.contains("2026-05-10"), "expected daily row\n{body}"); + assert!( + !body.contains("2026-04-05"), + "should not show other months\n{body}" + ); + } +} diff --git a/crates/tokscale-cli/src/tui/ui/overview.rs b/crates/tokscale-cli/src/tui/ui/overview.rs index 61b8c0ce4..320327fd7 100644 --- a/crates/tokscale-cli/src/tui/ui/overview.rs +++ b/crates/tokscale-cli/src/tui/ui/overview.rs @@ -1,10 +1,8 @@ use ratatui::prelude::*; -use ratatui::widgets::{ - Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, -}; +use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation}; use super::bar_chart::{render_stacked_bar_chart, ModelSegment, StackedBarData}; -use super::widgets::format_tokens; +use super::widgets::{format_tokens, viewport_scrollbar_state}; use crate::tui::app::{App, ChartGranularity}; use tokscale_core::GroupBy; @@ -375,7 +373,8 @@ fn render_top_models(frame: &mut Frame, app: &mut App, area: Rect, items_per_pag .track_symbol(Some("│")) .thumb_symbol("█"); - let mut scrollbar_state = ScrollbarState::new(models_len).position(scroll_offset); + let mut scrollbar_state = + viewport_scrollbar_state(models_len, scroll_offset, items_per_page); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/stats.rs b/crates/tokscale-cli/src/tui/ui/stats.rs index 60f2ed382..063e916d1 100644 --- a/crates/tokscale-cli/src/tui/ui/stats.rs +++ b/crates/tokscale-cli/src/tui/ui/stats.rs @@ -1,9 +1,9 @@ use ratatui::prelude::*; -use ratatui::widgets::{ - Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, -}; +use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation}; -use super::widgets::{format_cost, format_tokens, get_client_color, get_client_display_name}; +use super::widgets::{ + format_cost, format_tokens, get_client_color, get_client_display_name, viewport_scrollbar_state, +}; use crate::tui::app::{App, ClickAction}; const CELL_WIDTH: u16 = 2; @@ -641,8 +641,11 @@ fn render_breakdown_panel(frame: &mut Frame, app: &mut App, area: Rect) { .begin_symbol(Some("▲")) .end_symbol(Some("▼")); - let mut scrollbar_state = - ScrollbarState::new(app.stats_breakdown_total_lines).position(app.scroll_offset); + let mut scrollbar_state = viewport_scrollbar_state( + app.stats_breakdown_total_lines, + app.scroll_offset, + visible_height, + ); frame.render_stateful_widget( scrollbar, diff --git a/crates/tokscale-cli/src/tui/ui/usage.rs b/crates/tokscale-cli/src/tui/ui/usage.rs index 9d666c6b7..c9bd3ccf3 100644 --- a/crates/tokscale-cli/src/tui/ui/usage.rs +++ b/crates/tokscale-cli/src/tui/ui/usage.rs @@ -1,160 +1,3781 @@ +use ratatui::layout::Flex; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::widgets::{Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState}; -use crate::commands::usage::helpers; -use crate::tui::app::App; +use crate::commands::usage::{ + helpers, UsageFetchDiagnostic, UsageFetchDiagnosticSeverity, UsageMetric, UsageOutput, +}; +use crate::tui::app::{App, ClickAction}; +use crate::tui::codex_login::CodexLoginOutcome; +use crate::tui::privacy::looks_like_email; +use crate::tui::ui::widgets::{ + get_provider_shade, light_ratio_bar_spans, truncate_ellipsis as truncate_string, +}; -const BAR_WIDTH: usize = 20; +struct ButtonSpec { + label: String, + kind: ButtonKind, + action: ClickAction, +} + +#[derive(Clone, Copy)] +enum ButtonKind { + Primary, + Secondary, + Warning, + Danger, + Disabled, +} + +struct UsageProviderGroup<'a> { + provider: &'a str, + outputs: Vec<(usize, &'a UsageOutput)>, +} + +struct UsageInventory { + providers: usize, + saved: usize, + managed: usize, +} + +struct UsageRowView<'a> { + account: String, + account_summary: String, + plan: String, + limit: String, + reset: String, + readiness: UsageReadiness, + metric: Option<&'a UsageMetric>, +} pub fn render(frame: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(app.theme.border)) - .title(" Subscription Usage ") - .title_style(Style::default().fg(app.theme.foreground)) + .title(Span::styled( + " Usage ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )) + .title_top( + Line::from(Span::styled( + status_label(app), + app.theme.subtle_text_style(), + )) + .right_aligned(), + ) .style(Style::default().bg(app.theme.background)); let inner = block.inner(area); frame.render_widget(block, area); - if app.subscription_usage.is_empty() { + if inner.width == 0 || inner.height == 0 { + return; + } + + let content = render_action_bar(frame, app, inner); + let content = render_codex_login_panel(frame, app, content); + + let outputs = app.subscription_usage.clone(); + if outputs.is_empty() { if app.is_fetching_usage() { - render_fetching(frame, app, inner); + render_fetching(frame, app, content); } else if app.usage_fetch_attempted { - render_empty(frame, app, inner); + render_empty(frame, app, content); } else { - render_loading(frame, app, inner); + render_ready(frame, app, content); } - } else if app.subscription_usage.iter().all(|o| o.metrics.is_empty()) { - render_empty(frame, app, inner); } else { - render_loaded(frame, app, inner, &app.subscription_usage); + render_loaded(frame, app, content, &outputs); } } -fn render_fetching(frame: &mut Frame, app: &App, area: Rect) { - let center = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(40), - Constraint::Length(3), - Constraint::Percentage(40), - ]) - .split(area)[1]; +fn status_label(app: &App) -> String { + if app.is_fetching_usage() { + return "Syncing usage".to_string(); + } + if app.is_codex_login_running() { + return "Codex login".to_string(); + } + + let inventory = usage_inventory(&app.subscription_usage); + + if inventory.providers == 0 && app.usage_fetch_attempted { + if app.usage_fetch_diagnostics.is_empty() { + "No data".to_string() + } else { + usage_issue_count_label(app.usage_fetch_diagnostics.len()) + } + } else if inventory.providers == 0 { + "Not loaded".to_string() + } else if app.usage_fetch_diagnostics.is_empty() { + format!( + "{} providers · {}", + inventory.providers, + identity_count_label(inventory.saved, inventory.managed) + ) + } else { + format!( + "{} providers · {} · {}", + inventory.providers, + identity_count_label(inventory.saved, inventory.managed), + usage_issue_count_label(app.usage_fetch_diagnostics.len()) + ) + } +} + +fn usage_inventory(outputs: &[UsageOutput]) -> UsageInventory { + let providers = outputs + .iter() + .map(|output| output.provider.as_str()) + .collect::>() + .len(); + let saved = outputs + .iter() + .filter(|output| output.account.is_some()) + .count(); + UsageInventory { + providers, + saved, + managed: outputs.len().saturating_sub(saved), + } +} + +fn identity_count_label(saved: usize, managed: usize) -> String { + match (saved, managed) { + (0, 0) => "0 saved".to_string(), + (saved, 0) => format!("{saved} saved"), + (0, managed) => format!("{managed} managed"), + (saved, managed) => format!("{saved} saved · {managed} managed"), + } +} + +fn usage_issue_count_label(count: usize) -> String { + match count { + 1 => "1 issue".to_string(), + count => format!("{count} issues"), + } +} + +fn render_action_bar(frame: &mut Frame, app: &mut App, area: Rect) -> Rect { + if area.height == 0 { + return area; + } + let compact = area.width < 48; + let show_prefix = area.width >= 36; + + let refresh_label = if app.is_fetching_usage() { + if compact { "r Sync" } else { "r Syncing" }.to_string() + } else { + "r Refresh".to_string() + }; + let refresh_style = if app.is_fetching_usage() { + ButtonKind::Disabled + } else { + ButtonKind::Primary + }; + + let add_label = if app.is_codex_login_running() { + if compact { + "a Adding" + } else { + "a Adding Codex" + } + .to_string() + } else { + if compact { "a Add" } else { "a Add Codex" }.to_string() + }; + let add_style = if app.is_codex_login_running() { + ButtonKind::Disabled + } else { + ButtonKind::Secondary + }; + + let mut buttons = vec![ + ButtonSpec { + label: refresh_label, + kind: refresh_style, + action: ClickAction::UsageRefresh, + }, + ButtonSpec { + label: add_label, + kind: add_style, + action: ClickAction::CodexStartLogin, + }, + ]; + if !app.subscription_usage.is_empty() { + buttons.push(ButtonSpec { + label: if app.hide_usage_emails { + if compact { "m Show" } else { "m Show Emails" }.to_string() + } else { + if compact { "m Hide" } else { "m Hide Emails" }.to_string() + }, + kind: ButtonKind::Secondary, + action: ClickAction::UsageToggleEmailPrivacy, + }); + } + if let Some(button) = selected_reset_action_button(app) { + buttons.push(button); + } + + let mut spans = Vec::new(); + if show_prefix { + spans.push(Span::styled(" Actions ", app.theme.subtle_text_style())); + } + let start_x = area.x + Line::from(spans.clone()).width() as u16; + push_click_buttons(&mut spans, app, buttons, start_x, area.y, area.right()); + + frame.render_widget( + Paragraph::new(Line::from(spans)), + Rect::new(area.x, area.y, area.width, 1), + ); + + if area.height > 1 { + Rect::new(area.x, area.y + 1, area.width, area.height - 1) + } else { + Rect::new(area.x, area.y, area.width, 0) + } +} + +fn selected_reset_action_button(app: &App) -> Option { + let output = app.subscription_usage.get(app.selected_index)?; + if !has_available_reset_credit(output) { + return None; + } + + let account_id = output.account.as_ref()?.id.clone(); + Some(ButtonSpec { + label: "x Reset".to_string(), + kind: ButtonKind::Warning, + action: ClickAction::CodexResetAccount { account_id }, + }) +} + +fn push_click_buttons( + spans: &mut Vec>, + app: &mut App, + buttons: Vec, + start_x: u16, + y: u16, + right_edge: u16, +) { + let mut x = start_x; + for (index, button) in buttons.into_iter().enumerate() { + let rendered = button_label(&button.label); + let width = Line::from(rendered.as_str()).width() as u16; + let separator_width = u16::from(index > 0); + if x.saturating_add(separator_width).saturating_add(width) > right_edge { + break; + } + + if index > 0 { + spans.push(Span::raw(" ")); + x = x.saturating_add(1); + } + + spans.push(Span::styled( + rendered, + button_style(app, button.kind, false), + )); + + if x < right_edge { + app.add_click_area(Rect::new(x, y, width.min(right_edge - x), 1), button.action); + } + x = x.saturating_add(width); + } +} + +fn button_label(label: &str) -> String { + format!(" {label} ") +} + +fn button_style(app: &App, kind: ButtonKind, selected: bool) -> Style { + if selected { + return Style::default() + .fg(Color::White) + .bg(Color::Blue) + .add_modifier(Modifier::BOLD); + } + + match kind { + ButtonKind::Primary => Style::default() + .fg(app.theme.background) + .bg(app.theme.accent) + .add_modifier(Modifier::BOLD), + ButtonKind::Secondary => Style::default().fg(app.theme.accent).bg(app.theme.border), + ButtonKind::Warning => Style::default().fg(Color::Black).bg(Color::Yellow), + ButtonKind::Danger => Style::default().fg(Color::Red).bg(app.theme.border), + ButtonKind::Disabled => Style::default().fg(app.theme.muted).bg(app.theme.border), + } +} + +fn render_codex_login_panel(frame: &mut Frame, app: &mut App, area: Rect) -> Rect { + if area.height == 0 || !app.should_show_codex_login_panel() { + return area; + } + + let max_output_lines = 4usize; + let output_start = app.codex_login_lines.len().saturating_sub(max_output_lines); + let output_lines: Vec = app.codex_login_lines[output_start..].to_vec(); + let height = (2 + output_lines.len() as u16 + u16::from(app.codex_login_outcome.is_some())) + .min(area.height); + if height == 0 { + return area; + } + + let mut lines: Vec = Vec::new(); + let status = match &app.codex_login_outcome { + Some(CodexLoginOutcome::Imported(_)) => "Imported", + Some(CodexLoginOutcome::Failed(_)) => "Failed", + None if app.is_codex_login_running() => "Running", + None => "Idle", + }; + + let mut header_spans = vec![ + Span::styled( + " Codex Login ", + Style::default() + .fg(app.theme.foreground) + .add_modifier(Modifier::BOLD), + ), + Span::styled(status.to_string(), app.theme.subtle_text_style()), + ]; + if app.is_codex_login_running() || app.codex_login_outcome.is_some() { + let action_label = if app.is_codex_login_running() { + "[Cancel]" + } else { + "[Dismiss]" + }; + let action_width = action_label.chars().count() as u16; + let used_width = Line::from(header_spans.clone()).width(); + let padding = (area.width as usize).saturating_sub(used_width + action_width as usize); + header_spans.push(Span::raw(" ".repeat(padding))); + header_spans.push(Span::styled( + action_label, + Style::default().fg(app.theme.accent), + )); + let x = area + .x + .saturating_add(area.width.saturating_sub(action_width)); + app.add_click_area( + Rect::new(x, area.y, action_width.min(area.width), 1), + ClickAction::CodexDismissLogin, + ); + } + lines.push(Line::from(header_spans)); + + if output_lines.is_empty() { + lines.push(Line::from(Span::styled( + " Waiting for codex output...", + Style::default().fg(app.theme.muted), + ))); + } else { + for line in output_lines { + lines.push(Line::from(Span::styled( + format!( + " {}", + truncate_string(&line, area.width.saturating_sub(4) as usize) + ), + Style::default().fg(app.theme.muted), + ))); + } + } + + if let Some(outcome) = &app.codex_login_outcome { + let (label, style) = match outcome { + CodexLoginOutcome::Imported(info) => ( + format!( + " Imported {}", + info.label.as_deref().unwrap_or(info.id.as_str()) + ), + Style::default().fg(app.theme.accent), + ), + CodexLoginOutcome::Failed(error) => { + (format!(" {error}"), Style::default().fg(Color::Red)) + } + }; + lines.push(Line::from(Span::styled( + truncate_string(&label, area.width as usize), + style, + ))); + } + + frame.render_widget( + Paragraph::new(lines), + Rect::new(area.x, area.y, area.width, height), + ); + + if area.height > height { + Rect::new(area.x, area.y + height, area.width, area.height - height) + } else { + Rect::new(area.x, area.y, area.width, 0) + } +} +fn render_fetching(frame: &mut Frame, app: &App, area: Rect) { + let center = centered_rect(area, 3); let spin = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][app.spinner_frame % 10]; - let paragraph = Paragraph::new(format!("{spin} Fetching subscription data...")) + let message = if area.width < 40 { + format!("{spin} Fetching usage...") + } else { + format!("{spin} Fetching subscription data...") + }; + let paragraph = Paragraph::new(message) .style(Style::default().fg(app.theme.muted)) .alignment(Alignment::Center); frame.render_widget(paragraph, center); } -fn render_loading(frame: &mut Frame, app: &App, area: Rect) { - let center = Layout::default() +fn render_ready(frame: &mut Frame, app: &App, area: Rect) { + let center = centered_rect(area, 4); + let lines = if area.width < 40 { + vec![Line::from(Span::styled( + "No usage data", + Style::default() + .fg(app.theme.foreground) + .add_modifier(Modifier::BOLD), + ))] + } else { + vec![ + Line::from(Span::styled( + "No subscription data loaded", + Style::default() + .fg(app.theme.foreground) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "Use Refresh to sync provider usage, or Add Codex to save another account.", + Style::default().fg(app.theme.muted), + )), + ] + }; + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(paragraph, center); +} + +fn render_empty(frame: &mut Frame, app: &App, area: Rect) { + let center = centered_rect(area, 4); + let lines = if let Some(diagnostic) = app.usage_fetch_diagnostics.first() { + if area.width < 40 { + vec![ + Line::from(Span::styled( + "Usage fetch failed", + Style::default().fg(app.theme.muted), + )), + Line::from(Span::styled( + truncate_string(&diagnostic.display_name(), area.width as usize), + Style::default().fg(diagnostic_severity_color(diagnostic.severity)), + )), + ] + } else { + vec![ + Line::from(Span::styled( + "Usage fetch failed", + Style::default() + .fg(app.theme.foreground) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + truncate_string(&diagnostic.display_name(), area.width as usize), + Style::default().fg(diagnostic_severity_color(diagnostic.severity)), + )), + Line::from(Span::styled( + truncate_string(&diagnostic.message, area.width as usize), + Style::default().fg(app.theme.muted), + )), + ] + } + } else if area.width < 40 { + vec![Line::from(Span::styled( + "No usage data", + Style::default().fg(app.theme.muted), + ))] + } else { + vec![Line::from(Span::styled( + "No subscription data available", + Style::default().fg(app.theme.muted), + ))] + }; + let paragraph = Paragraph::new(lines).alignment(Alignment::Center); + frame.render_widget(paragraph, center); +} + +fn centered_rect(area: Rect, height: u16) -> Rect { + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage(40), - Constraint::Length(3), + Constraint::Length(height.min(area.height)), Constraint::Percentage(40), ]) - .split(area)[1]; + .split(area); + chunks[1] +} + +fn render_loaded(frame: &mut Frame, app: &mut App, area: Rect, outputs: &[UsageOutput]) { + app.selected_index = app.selected_index.min(outputs.len().saturating_sub(1)); + + if area.width < 104 || area.height < 20 { + render_compact_loaded(frame, app, area, outputs); + return; + } - let msg = if app.data.loading { - "Loading subscription data..." + if area.width < 132 { + render_medium_loaded(frame, app, area, outputs); + return; + } + + let top_height = if area.height >= 36 { + 19 + } else if area.height >= 31 { + 17 } else { - "Press 'u' to fetch subscription usage" + (area.height / 2).clamp(9, 13) }; - let paragraph = Paragraph::new(msg) - .style(Style::default().fg(app.theme.muted)) - .alignment(Alignment::Center); - frame.render_widget(paragraph, center); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(top_height), Constraint::Min(0)]) + .split(area); + + let (summary_width, detail_width) = usage_top_column_percentages(area.width); + let top = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(summary_width), + Constraint::Percentage(detail_width), + ]) + .split(chunks[0]); + + render_usage_status(frame, app, top[0], outputs); + let selected_index = app.selected_index; + render_selected_account(frame, app, top[1], &outputs[selected_index], outputs); + render_accounts_table(frame, app, chunks[1], outputs); } -fn render_empty(frame: &mut Frame, app: &App, area: Rect) { - let center = Layout::default() +fn render_medium_loaded(frame: &mut Frame, app: &mut App, area: Rect, outputs: &[UsageOutput]) { + let selected_index = app.selected_index; + let selected = &outputs[selected_index]; + let summary_height = if area.height >= 36 { 8 } else { 6 }.min(area.height); + let base_selected_height = if area.height >= 36 { 11 } else { 9 }; + // Reserve enough rows for the accounts table's borders + header + a + // handful of account rows so the dynamic selected-panel height can only + // grow into space that is genuinely spare, never collapsing the table. + let accounts_table_min_height: u16 = 9; + let selected_height = if has_available_reset_credit(selected) { + let max_dynamic_height = area + .height + .saturating_sub(summary_height) + .saturating_sub(accounts_table_min_height) + .max(base_selected_height); + medium_selected_account_preferred_height(selected).min(max_dynamic_height) + } else { + base_selected_height + } + .min(area.height.saturating_sub(summary_height)); + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Percentage(40), - Constraint::Length(3), - Constraint::Percentage(40), + Constraint::Length(summary_height), + Constraint::Length(selected_height), + Constraint::Min(0), ]) - .split(area)[1]; + .split(area); - let paragraph = Paragraph::new("No subscription data available") - .style(Style::default().fg(app.theme.muted)) - .alignment(Alignment::Center); - frame.render_widget(paragraph, center); + render_usage_status(frame, app, chunks[0], outputs); + render_selected_account(frame, app, chunks[1], &outputs[selected_index], outputs); + render_accounts_table(frame, app, chunks[2], outputs); } -fn render_loaded( - frame: &mut Frame, - app: &App, - area: Rect, - outputs: &[crate::commands::usage::UsageOutput], -) { - let mut lines: Vec = Vec::new(); +fn medium_selected_account_preferred_height(selected: &UsageOutput) -> u16 { + let status_rows = 3 + + usize::from(credits_status_line(selected).is_some()) + + reset_credit_detail_rows(selected); + let limit_rows = 2 + selected.metrics.len().min(4); + let action_rows = 3; + (status_rows + limit_rows + action_rows + 2).clamp(9, 22) as u16 +} - for (i, output) in outputs.iter().enumerate() { - if i > 0 { - lines.push(Line::from("")); - } +fn usage_top_column_percentages(width: u16) -> (u16, u16) { + if width >= 128 { + (50, 50) + } else { + (48, 52) + } +} - lines.push(Line::from(Span::styled( - format!(" {} ", output.provider), +fn render_compact_loaded(frame: &mut Frame, app: &mut App, area: Rect, outputs: &[UsageOutput]) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(7.min(area.height))]) + .split(area); + render_accounts_table(frame, app, chunks[0], outputs); + if chunks.len() > 1 && chunks[1].height > 0 { + let selected_index = app.selected_index; + render_selected_account(frame, app, chunks[1], &outputs[selected_index], outputs); + } +} + +fn render_usage_status(frame: &mut Frame, app: &mut App, area: Rect, outputs: &[UsageOutput]) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + " Usage Summary ", Style::default() - .fg(app.theme.foreground) + .fg(app.theme.accent) .add_modifier(Modifier::BOLD), - ))); - - for m in &output.metrics { - let remaining = m - .remaining_label - .clone() - .unwrap_or_else(|| format!("{:.0}% left", m.remaining_percent)); - let bar = helpers::render_ascii_bar(m.remaining_percent, BAR_WIDTH); - let reset = m - .resets_at - .as_ref() - .map(|r| helpers::format_reset_time(r)) - .unwrap_or_default(); + )); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } - let label = Span::styled( - format!(" {:<14}", m.label), - Style::default().fg(app.theme.foreground), - ); - let value = Span::styled( - format!("{:<11}", remaining), - Style::default().fg(app.theme.foreground), - ); - let bar_span = Span::styled( - format!("{:<24}", bar), - Style::default().fg(if m.remaining_percent < 10.0 { - Color::Red - } else if m.remaining_percent < 25.0 { - Color::Yellow - } else { - app.theme.accent - }), - ); - let reset_span = Span::styled(reset, Style::default().fg(app.theme.muted)); + let diagnostic_reserve = diagnostic_line_reserve(app, inner.height as usize); + let summary_height = (inner.height as usize).saturating_sub(diagnostic_reserve); + let mut lines = usage_status_summary_lines(app, outputs, inner.width as usize, summary_height); + append_usage_diagnostic_lines(&mut lines, app, inner.width as usize, inner.height as usize); - lines.push(Line::from(vec![label, value, bar_span, reset_span])); - } + push_section_spacing(&mut lines, inner.height as usize); + append_credit_bank_summary_lines( + &mut lines, + app, + outputs, + inner.width as usize, + inner.height as usize, + ); - if let Some(ref email) = output.email { + let attention_outputs = attention_outputs(outputs); + push_section_spacing(&mut lines, inner.height as usize); + if lines.len() + 1 < inner.height as usize { + lines.push(section_heading("Attention", app)); + if attention_outputs.is_empty() { lines.push(Line::from(Span::styled( - format!(" {:<12}{email}", "Account"), - Style::default().fg(app.theme.muted), + " No accounts need attention", + app.theme.subtle_text_style(), ))); + } else { + let available = (inner.height as usize).saturating_sub(lines.len()); + let mut visible_count = attention_outputs.len().min(available.min(2)); + if attention_outputs.len() > visible_count && visible_count == available { + visible_count = visible_count.saturating_sub(1); + } + + for (index, output) in attention_outputs.iter().take(visible_count).copied() { + let y = inner.y.saturating_add(lines.len() as u16); + app.add_click_area( + Rect::new(inner.x, y, inner.width, 1), + ClickAction::UsageSelect { index }, + ); + lines.push(attention_line(app, output, inner.width as usize)); + } + + let hidden_count = attention_outputs.len().saturating_sub(visible_count); + if hidden_count > 0 && lines.len() < inner.height as usize { + lines.push(attention_more_line(hidden_count, inner.width as usize)); + } } - if let Some(ref plan) = output.plan { - lines.push(Line::from(Span::styled( - format!(" {:<12}{plan}", "Plan"), - Style::default().fg(app.theme.muted), - ))); + } + + push_section_spacing(&mut lines, inner.height as usize); + append_provider_summary_lines( + &mut lines, + app, + outputs, + inner.width as usize, + inner.height as usize, + ); + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn usage_status_summary_lines( + app: &App, + outputs: &[UsageOutput], + width: usize, + height: usize, +) -> Vec> { + let ready_count = outputs + .iter() + .filter(|output| readiness_status(output) == UsageReadiness::Ready) + .count(); + let watch_count = outputs + .iter() + .filter(|output| readiness_status(output) == UsageReadiness::Watch) + .count(); + let critical_count = outputs + .iter() + .filter(|output| readiness_status(output) == UsageReadiness::Critical) + .count(); + let unknown_count = outputs + .iter() + .filter(|output| readiness_status(output) == UsageReadiness::Unknown) + .count(); + + let overall = overall_readiness(outputs); + let active = active_output(outputs) + .map(|output| account_name(app, output)) + .unwrap_or_else(|| "No active account".to_string()); + let fallback = best_fallback_output(outputs) + .map(|output| { + let score = output_score(output); + if score > 0.0 { + format!("{} · {:.0}% left", account_name(app, output), score) + } else { + account_name(app, output) + } + }) + .unwrap_or_else(|| "No ready fallback".to_string()); + let next_reset = next_reset_label(app, outputs).unwrap_or_else(|| "No reset data".to_string()); + let action = overall_action(app, outputs); + let capacity = format!( + "{ready_count} ready · {watch_count} watch · {critical_count} critical{}", + if unknown_count > 0 { + format!(" · {unknown_count} unknown") + } else { + String::new() + } + ); + + let mut lines = Vec::new(); + let push_state = |lines: &mut Vec>| { + push_kv_styled( + lines, + app, + "State", + overall_state_label(outputs), + Style::default() + .fg(readiness_color(app, overall)) + .add_modifier(Modifier::BOLD), + width, + ); + }; + let push_active = |lines: &mut Vec>| { + push_kv_styled( + lines, + app, + "Active", + &active, + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + width, + ); + }; + let push_capacity = |lines: &mut Vec>| { + push_kv_styled( + lines, + app, + "Capacity", + &capacity, + app.theme.secondary_text_style(), + width, + ); + }; + let push_fallback = |lines: &mut Vec>| { + push_kv_styled( + lines, + app, + "Fallback", + &fallback, + app.theme.secondary_text_style(), + width, + ); + }; + let push_next_reset = |lines: &mut Vec>| { + push_kv_styled( + lines, + app, + "Next Reset", + &next_reset, + app.theme.secondary_text_style(), + width, + ); + }; + let push_action = |lines: &mut Vec>| { + push_kv_styled( + lines, + app, + "Action", + &action, + Style::default() + .fg(readiness_color(app, overall)) + .add_modifier(Modifier::BOLD), + width, + ); + }; + + match height { + 0 => {} + 1 => push_state(&mut lines), + 2 => { + push_state(&mut lines); + push_action(&mut lines); + } + 3 => { + push_state(&mut lines); + push_active(&mut lines); + push_action(&mut lines); + } + 4 => { + push_state(&mut lines); + push_active(&mut lines); + push_capacity(&mut lines); + push_action(&mut lines); } + 5 => { + push_state(&mut lines); + push_active(&mut lines); + push_capacity(&mut lines); + push_next_reset(&mut lines); + push_action(&mut lines); + } + _ => { + push_state(&mut lines); + push_active(&mut lines); + push_capacity(&mut lines); + push_fallback(&mut lines); + push_next_reset(&mut lines); + push_action(&mut lines); + } + } + lines +} + +fn diagnostic_line_reserve(app: &App, max_lines: usize) -> usize { + if app.usage_fetch_diagnostics.is_empty() { + 0 + } else if max_lines >= 7 { + 3 + } else { + 2.min(max_lines.saturating_sub(1)) + } +} + +fn append_usage_diagnostic_lines( + lines: &mut Vec>, + app: &App, + width: usize, + max_lines: usize, +) { + if app.usage_fetch_diagnostics.is_empty() || lines.len() >= max_lines { + return; + } + + let remaining = max_lines.saturating_sub(lines.len()); + if remaining < 2 { + return; + } + if !lines.is_empty() && remaining >= 3 { + lines.push(Line::from("")); + } + if max_lines.saturating_sub(lines.len()) < 2 { + return; + } + + lines.push(section_heading("Diagnostics", app)); + let available = max_lines.saturating_sub(lines.len()); + if available == 0 { + return; + } + + let visible_count = if app.usage_fetch_diagnostics.len() > available && available > 1 { + available - 1 + } else { + app.usage_fetch_diagnostics.len().min(available) + }; + for diagnostic in visible_usage_diagnostics(&app.usage_fetch_diagnostics, visible_count) { + lines.push(usage_diagnostic_line(diagnostic, width)); + } + + let hidden_count = app + .usage_fetch_diagnostics + .len() + .saturating_sub(visible_count); + if hidden_count > 0 && lines.len() < max_lines { + lines.push(Line::from(Span::styled( + truncate_string( + &format!( + " +{} more issue{}", + hidden_count, + if hidden_count == 1 { "" } else { "s" } + ), + width, + ), + app.theme.subtle_text_style(), + ))); + } +} + +fn visible_usage_diagnostics( + diagnostics: &[UsageFetchDiagnostic], + visible_count: usize, +) -> Vec<&UsageFetchDiagnostic> { + let visible_count = visible_count.min(diagnostics.len()); + if visible_count >= diagnostics.len() { + return diagnostics.iter().collect(); } - let paragraph = Paragraph::new(lines); - frame.render_widget(paragraph, area); + let mut indexed: Vec<_> = diagnostics.iter().enumerate().collect(); + indexed + .sort_by_key(|(index, diagnostic)| (diagnostic_severity_rank(diagnostic.severity), *index)); + indexed.truncate(visible_count); + indexed + .into_iter() + .map(|(_, diagnostic)| diagnostic) + .collect() +} + +fn diagnostic_severity_rank(severity: UsageFetchDiagnosticSeverity) -> u8 { + match severity { + UsageFetchDiagnosticSeverity::Error => 0, + UsageFetchDiagnosticSeverity::Warning => 1, + UsageFetchDiagnosticSeverity::Info => 2, + } +} + +fn usage_diagnostic_line(diagnostic: &UsageFetchDiagnostic, width: usize) -> Line<'static> { + let label = diagnostic.display_name(); + let text = format!(" {label}: {}", diagnostic.message); + Line::from(Span::styled( + truncate_string(&text, width), + Style::default().fg(diagnostic_severity_color(diagnostic.severity)), + )) +} + +fn diagnostic_severity_color(severity: UsageFetchDiagnosticSeverity) -> Color { + match severity { + UsageFetchDiagnosticSeverity::Info => Color::Cyan, + UsageFetchDiagnosticSeverity::Warning => Color::Yellow, + UsageFetchDiagnosticSeverity::Error => Color::Red, + } +} + +fn push_section_spacing(lines: &mut Vec>, max_lines: usize) { + if !lines.is_empty() && lines.len().saturating_add(1) < max_lines { + lines.push(Line::from("")); + } +} + +fn append_provider_summary_lines( + lines: &mut Vec>, + app: &App, + outputs: &[UsageOutput], + width: usize, + max_lines: usize, +) { + if lines.len().saturating_add(1) >= max_lines { + return; + } + + lines.push(section_heading("Providers", app)); + + for group in group_outputs_by_provider(outputs) { + if lines.len() >= max_lines { + break; + } + lines.push(provider_summary_line(app, &group, width)); + } +} + +fn section_heading(label: &'static str, app: &App) -> Line<'static> { + Line::from(Span::styled( + format!(" {label}"), + section_heading_style(app), + )) +} + +fn section_heading_style(app: &App) -> Style { + app.theme + .secondary_text_style() + .add_modifier(Modifier::BOLD) +} + +fn push_kv_styled( + lines: &mut Vec>, + app: &App, + key: &'static str, + value: &str, + value_style: Style, + width: usize, +) { + let max_value = width.saturating_sub(16); + lines.push(Line::from(vec![ + Span::styled(format!(" {:<12}", key), app.theme.subtle_text_style()), + Span::styled(truncate_string(value, max_value), value_style), + ])); +} + +fn attention_outputs(outputs: &[UsageOutput]) -> Vec<(usize, &UsageOutput)> { + let mut items: Vec<(usize, &UsageOutput)> = outputs + .iter() + .enumerate() + .filter(|(_, output)| readiness_status(output).is_at_risk()) + .collect(); + + items.sort_by(|(left_index, left), (right_index, right)| { + attention_severity_rank(left) + .cmp(&attention_severity_rank(right)) + .then_with(|| attention_action_rank(left).cmp(&attention_action_rank(right))) + .then_with(|| output_score(left).total_cmp(&output_score(right))) + .then_with(|| left_index.cmp(right_index)) + }); + + items +} + +fn attention_severity_rank(output: &UsageOutput) -> u8 { + match readiness_status(output) { + UsageReadiness::Critical => 0, + UsageReadiness::Watch => 1, + UsageReadiness::Ready => 2, + UsageReadiness::Unknown => 3, + } +} + +fn attention_action_rank(output: &UsageOutput) -> u8 { + match &output.account { + Some(account) if account.is_active => 0, + Some(_) => 1, + None => 2, + } +} + +fn attention_line(app: &App, output: &UsageOutput, width: usize) -> Line<'static> { + let status = readiness_status(output); + let metric = display_metric(output); + let detail = metric + .map(|metric| { + let reset = metric + .resets_at + .as_ref() + .map(|reset| format!(" · {}", helpers::format_reset_time(reset))) + .unwrap_or_default(); + format!( + "{} {}{}", + compact_metric_label(&metric.label), + remaining_label(metric), + reset + ) + }) + .unwrap_or_else(|| "No quota metrics".to_string()); + let account_width: usize = if width >= 52 { 24 } else { 18 }; + let used = 2 + 11 + account_width; + Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{:<11}", readiness_label(status)), + Style::default().fg(readiness_color(app, status)), + ), + Span::styled( + format!( + "{: Line<'static> { + let label = if hidden_count == 1 { + "+1 more at risk".to_string() + } else { + format!("+{hidden_count} more at risk") + }; + Line::from(Span::styled( + format!(" {}", truncate_string(&label, width.saturating_sub(2))), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )) +} + +fn provider_summary_line( + app: &App, + group: &UsageProviderGroup<'_>, + _width: usize, +) -> Line<'static> { + let saved = group + .outputs + .iter() + .filter(|(_, output)| output.account.is_some()) + .count(); + let managed = group.outputs.len().saturating_sub(saved); + let count_label = identity_count_label(saved, managed); + let ready = group + .outputs + .iter() + .filter(|(_, output)| readiness_status(output) == UsageReadiness::Ready) + .count(); + let risk = group + .outputs + .iter() + .filter(|(_, output)| readiness_status(output).is_at_risk()) + .count(); + let summary = if risk > 0 { + format!("{count_label} · {ready} ready · {risk} at risk") + } else { + format!("{count_label} · {ready} ready") + }; + Line::from(vec![ + Span::styled( + format!(" {}", truncate_string(group.provider, 18)), + Style::default() + .fg(get_provider_shade(group.provider, 0)) + .add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" {summary}"), app.theme.subtle_text_style()), + ]) +} + +fn render_selected_account( + frame: &mut Frame, + app: &mut App, + area: Rect, + selected: &UsageOutput, + outputs: &[UsageOutput], +) { + let title = format!(" Selected Account {} ", output_display_name(app, selected)); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + truncate_string(&title, area.width.saturating_sub(4) as usize), + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let readiness = readiness_status(selected); + let max_lines = inner.height as usize; + let action_lines = max_lines.min(2); + let detail_limit = max_lines.saturating_sub(action_lines); + let mut lines = Vec::new(); + + if lines.len() < detail_limit { + push_kv_styled( + &mut lines, + app, + "Status", + &selected_status_line(selected), + Style::default() + .fg(readiness_color(app, readiness)) + .add_modifier(Modifier::BOLD), + inner.width as usize, + ); + } + if lines.len() < detail_limit { + push_kv_styled( + &mut lines, + app, + "Email", + &email_display(app, selected.email.as_deref()), + app.theme.secondary_text_style(), + inner.width as usize, + ); + } + if lines.len() < detail_limit { + if let Some(account) = &selected.account { + push_kv_styled( + &mut lines, + app, + "Credential", + if account.is_active { + "saved store, current Codex login" + } else { + "saved store" + }, + app.theme.secondary_text_style(), + inner.width as usize, + ); + } else { + push_kv_styled( + &mut lines, + app, + "Credential", + "managed externally", + app.theme.secondary_text_style(), + inner.width as usize, + ); + } + } + if lines.len() < detail_limit { + if let Some(label) = credits_status_line(selected) { + push_kv_styled( + &mut lines, + app, + "Credits", + &label, + app.theme.secondary_text_style(), + inner.width as usize, + ); + } + } + append_selected_reset_credit_lines( + &mut lines, + app, + selected, + inner.width as usize, + detail_limit, + ); + push_section_spacing(&mut lines, detail_limit); + if lines.len() < detail_limit { + lines.push(section_heading("Limits", app)); + } + let mut metric_index = 0usize; + while lines.len() < detail_limit { + if selected.metrics.is_empty() { + lines.push(Line::from(Span::styled( + " No quota metrics returned", + app.theme.subtle_text_style(), + ))); + break; + } + if let Some(metric) = selected.metrics.get(metric_index) { + lines.push(metric_detail_line(app, metric, inner.width as usize)); + metric_index += 1; + } else { + break; + } + } + if lines.len() < detail_limit { + lines.push(snapshot_line(app, outputs, inner.width as usize)); + } + push_section_spacing(&mut lines, max_lines); + if lines.len() + 1 < max_lines { + lines.push(section_heading("Actions", app)); + } + if lines.len() < max_lines { + let y = inner.y.saturating_add(lines.len() as u16); + lines.push(selected_account_actions_line(app, selected, inner, y)); + } + + frame.render_widget(Paragraph::new(lines), inner); +} + +fn append_selected_reset_credit_lines( + lines: &mut Vec>, + app: &App, + selected: &UsageOutput, + width: usize, + max_lines: usize, +) { + if lines.len() >= max_lines { + return; + } + + let Some(credits) = selected.reset_credits.as_ref() else { + return; + }; + + let count_label = reset_credit_count_label(credits.available_count); + let value_style = if has_available_reset_credit(selected) { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + app.theme.secondary_text_style() + }; + push_kv_styled(lines, app, "Reset Bank", &count_label, value_style, width); + + if lines.len() >= max_lines { + return; + } + + let buckets = reset_credit_buckets(credits); + if buckets.is_empty() { + if credits.available_count > 0 { + lines.push(selected_reset_schedule_line( + "expiry unknown", + app.theme.subtle_text_style(), + width, + )); + } + return; + } + + // Leave room for the sections rendered after the reset schedule (a blank + // spacer, the "Limits" heading, at least one metric row, and the + // snapshot line) so a long expiry list can't push them off screen, while + // still keeping a small floor for an expiry line plus "+N more". + let trailing_rows_reserved = 4usize; + let expiry_budget = max_lines + .saturating_sub(trailing_rows_reserved) + .max(lines.len() + 2); + let available = expiry_budget.saturating_sub(lines.len()); + let visible_count = if buckets.len() > available { + available.saturating_sub(1) + } else { + buckets.len() + }; + + for bucket in buckets.iter().take(visible_count) { + lines.push(selected_reset_schedule_line( + &format_selected_reset_schedule_entry(bucket), + app.theme.secondary_text_style(), + width, + )); + } + + let hidden = hidden_expiry_count(&buckets[visible_count..]); + if hidden > 0 && lines.len() < max_lines { + lines.push(selected_reset_schedule_line( + &format!("+{hidden} more reset credits"), + app.theme.subtle_text_style(), + width, + )); + } +} + +fn selected_reset_schedule_line(value: &str, value_style: Style, width: usize) -> Line<'static> { + let prefix_width = 14usize; + Line::from(vec![ + Span::raw(" ".repeat(prefix_width)), + Span::styled( + truncate_string(value, width.saturating_sub(prefix_width + 2)), + value_style, + ), + ]) +} + +fn format_selected_reset_schedule_entry(bucket: &(String, usize)) -> String { + if bucket.1 > 1 { + format!("x{} expires {}", bucket.1, bucket.0) + } else { + format!("expires {}", bucket.0) + } +} + +fn reset_credit_detail_rows(selected: &UsageOutput) -> usize { + let Some(credits) = selected.reset_credits.as_ref() else { + return 0; + }; + + if credits.available_count == 0 { + return 1; + } + + let bucket_count = reset_credit_buckets(credits).len(); + 1 + bucket_count.max(1) +} + +fn append_credit_bank_summary_lines( + lines: &mut Vec>, + app: &App, + outputs: &[UsageOutput], + width: usize, + max_lines: usize, +) { + if !outputs.iter().any(has_available_reset_credit) || lines.len() >= max_lines { + return; + } + + lines.push(Line::from(vec![ + Span::styled(" Credit Bank ", section_heading_style(app)), + Span::styled( + truncate_string(&reset_bank_summary(outputs), width.saturating_sub(15)), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ])); + + for output in outputs.iter().filter(|output| output.provider == "Codex") { + if lines.len() >= max_lines { + break; + } + let Some(credits) = output.reset_credits.as_ref() else { + continue; + }; + if credits.available_count == 0 { + continue; + } + lines.push(reset_credit_account_line( + app, + output, + credits, + output + .account + .as_ref() + .is_some_and(|account| account.is_active), + width, + )); + } +} + +fn reset_credit_account_line( + app: &App, + output: &UsageOutput, + credits: &crate::commands::usage::UsageResetCredits, + selected: bool, + width: usize, +) -> Line<'static> { + let account = account_name(app, output); + let count = if credits.available_count == 1 { + "1 credit".to_string() + } else { + format!("{} credits", credits.available_count) + }; + let label_width: usize = if width >= 72 { 28 } else { 18 }; + let count_width: usize = if width >= 72 { 12 } else { 10 }; + let used = 2 + label_width + count_width + 2; + let expiry = credit_nearest_expiry_line(credits); + let marker = if selected { "> " } else { " " }; + Line::from(vec![ + Span::raw(marker), + Span::styled( + format!( + "{: Vec<(String, usize)> { + credit_expiry_buckets( + credits + .credits + .iter() + .filter_map(|credit| credit.expires_at.as_deref()), + ) +} + +fn credit_nearest_expiry_line(credits: &crate::commands::usage::UsageResetCredits) -> String { + nearest_credit_expiry_label(&reset_credit_buckets(credits)) + .unwrap_or_else(|| "expiry unknown".to_string()) +} + +fn nearest_credit_expiry_label(buckets: &[(String, usize)]) -> Option { + buckets + .first() + .map(|bucket| format!("nearest expires {}", bucket.0)) +} + +fn credit_expiry_buckets<'a>(expiries: impl Iterator) -> Vec<(String, usize)> { + let mut values: Vec = expiries.map(str::to_string).collect(); + // Sort by the raw RFC3339 value so buckets stay in chronological order, + // then group by the formatted label below, since `format_reset_time` has + // only minute granularity and would otherwise render two entries whose + // raw timestamps differ only by seconds as separate identical lines. + values.sort(); + + let mut buckets: Vec<(String, usize)> = Vec::new(); + for value in values { + let label = format_credit_expiry_label(&value); + if let Some(last) = buckets + .last_mut() + .filter(|(last_label, _)| *last_label == label) + { + last.1 += 1; + continue; + } + buckets.push((label, 1)); + } + + buckets +} + +fn hidden_expiry_count(buckets: &[(String, usize)]) -> usize { + buckets.iter().map(|(_, count)| *count).sum() +} + +fn format_credit_expiry_label(value: &str) -> String { + let label = format_expiry_time(value); + label + .strip_prefix("expires ") + .or_else(|| label.strip_prefix("resets ")) + .unwrap_or(&label) + .to_string() +} + +fn selected_status_line(output: &UsageOutput) -> String { + let plan = output.plan.as_deref().unwrap_or("Unknown"); + format!( + "{} · {}", + plan, + account_readiness_label(output, readiness_status(output)) + ) +} + +fn selected_account_actions_line( + app: &mut App, + selected: &UsageOutput, + area: Rect, + y: u16, +) -> Line<'static> { + if let Some(account) = &selected.account { + let mut spans = Vec::new(); + let mut buttons = Vec::new(); + if has_available_reset_credit(selected) { + buttons.push(reset_account_button(&account.id)); + } + if account.is_active { + spans.push(Span::styled( + " Current account ", + app.theme.subtle_text_style(), + )); + let x = area + .x + .saturating_add(Line::from(spans.clone()).width() as u16); + push_click_buttons(&mut spans, app, buttons, x, y, area.right()); + } else { + spans.push(Span::raw(" ")); + let x = area.x.saturating_add(2); + let mut all_buttons = vec![use_account_button(&account.id)]; + all_buttons.extend(buttons); + all_buttons.push(remove_account_button(&account.id)); + push_click_buttons(&mut spans, app, all_buttons, x, y, area.right()); + } + return Line::from(spans); + } + + Line::from(Span::styled( + " Managed externally", + app.theme.subtle_text_style(), + )) +} + +fn account_plan_label(account: &str, plan: Option<&str>) -> String { + match plan.map(str::trim).filter(|plan| !plan.is_empty()) { + Some(plan) => format!("{account} {plan}"), + None => account.to_string(), + } +} + +fn account_readiness_label(output: &UsageOutput, readiness: UsageReadiness) -> String { + let account_state = account_state_label(output); + let readiness = readiness_label(readiness); + if account_state.eq_ignore_ascii_case(readiness) { + readiness.to_string() + } else { + format!("{account_state} · {readiness}") + } +} + +fn snapshot_line(app: &App, outputs: &[UsageOutput], width: usize) -> Line<'static> { + let ready = outputs + .iter() + .filter(|output| readiness_status(output) == UsageReadiness::Ready) + .count(); + let at_risk = outputs + .iter() + .filter(|output| readiness_status(output).is_at_risk()) + .count(); + let inventory = usage_inventory(outputs); + let summary = format!( + " Snapshot {ready} ready · {at_risk} at risk · {}{}", + identity_count_label(inventory.saved, inventory.managed), + if app.hide_usage_emails { + " · emails hidden" + } else { + "" + } + ); + Line::from(Span::styled( + truncate_string(&summary, width), + app.theme.subtle_text_style(), + )) +} + +fn metric_detail_line(app: &App, metric: &UsageMetric, width: usize) -> Line<'static> { + let remaining = remaining_label(metric); + let label_width = metric_label_width(width); + let target_reset_width = width.saturating_sub(label_width + 12).min(24); + let bar_width = width + .saturating_sub(label_width + target_reset_width + 14) + .clamp(10, 34); + let reset_width = width.saturating_sub(label_width + bar_width + 14); + let reset = metric + .resets_at + .as_ref() + .map(|r| helpers::format_reset_time(r)) + .unwrap_or_default(); + let color = metric_color(app, metric); + let mut spans = vec![Span::styled( + format!( + " {: usize { + if width >= 96 { + 18 + } else if width >= 78 { + 14 + } else { + 10 + } +} + +fn render_accounts_table(frame: &mut Frame, app: &mut App, area: Rect, outputs: &[UsageOutput]) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.border)) + .title(Span::styled( + " Accounts ", + Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD), + )); + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + if inner.width < 132 { + render_narrow_accounts_table(frame, app, inner, outputs); + return; + } + + let max_rows = inner.height.saturating_sub(1) as usize; + app.set_max_visible_items(max_rows.max(1)); + let start = app + .scroll_offset + .min(outputs.len().saturating_sub(max_rows)); + let visible_rows = outputs + .iter() + .enumerate() + .skip(start) + .take(max_rows) + .collect::>(); + + let rows = visible_rows + .iter() + .map(|(index, output)| account_table_row(app, output, *index)) + .collect::>(); + + let selected_visible = app + .selected_index + .checked_sub(start) + .filter(|index| *index < visible_rows.len()); + let mut table_state = TableState::default().with_selected(selected_visible); + let table = Table::new(rows, account_table_widths(inner.width)) + .header(account_table_header(app)) + .column_spacing(1) + .highlight_spacing(HighlightSpacing::Never) + .row_highlight_style(Style::default().bg(app.theme.selection)) + .flex(Flex::Start); + frame.render_stateful_widget(table, inner, &mut table_state); + + for (visible_row, (index, _)) in visible_rows.into_iter().enumerate() { + let y = inner.y.saturating_add(1 + visible_row as u16); + app.add_click_area( + Rect::new(inner.x, y, inner.width, 1), + ClickAction::UsageSelect { index }, + ); + } +} + +fn render_narrow_accounts_table( + frame: &mut Frame, + app: &mut App, + area: Rect, + outputs: &[UsageOutput], +) { + let row_height = 2usize; + let max_items = (area.height.saturating_sub(1) as usize / row_height).max(1); + app.set_max_visible_items(max_items); + let start = app + .scroll_offset + .min(outputs.len().saturating_sub(max_items)); + let visible_rows = outputs + .iter() + .enumerate() + .skip(start) + .take(max_items) + .collect::>(); + + let rows = visible_rows + .iter() + .map(|(index, output)| narrow_table_row(app, output, *index, area)) + .collect::>(); + let selected_visible = app + .selected_index + .checked_sub(start) + .filter(|index| *index < visible_rows.len()); + let mut table_state = TableState::default().with_selected(selected_visible); + let table = Table::new(rows, [Constraint::Percentage(100)]) + .header(Row::new([Cell::from(narrow_table_header(app, area.width))])) + .highlight_spacing(HighlightSpacing::Never) + .row_highlight_style(Style::default().bg(app.theme.selection)) + .flex(Flex::Start); + frame.render_stateful_widget(table, area, &mut table_state); + + for (visible_row, (index, _)) in visible_rows.into_iter().enumerate() { + let y = area + .y + .saturating_add(1) + .saturating_add((visible_row * row_height) as u16); + app.add_click_area( + Rect::new( + area.x, + y, + area.width, + row_height.min(area.bottom().saturating_sub(y) as usize) as u16, + ), + ClickAction::UsageSelect { index }, + ); + } +} + +fn account_table_header(app: &App) -> Row<'static> { + let style = app.theme.subtle_text_style(); + Row::new([ + table_right_cell("#", style), + table_text_cell("Provider", style), + table_text_cell("Account", style), + table_text_cell("Plan", style), + table_text_cell("Auth", style), + table_text_cell("Health", style), + table_text_cell("Limit", style), + table_text_cell("Reset", style), + ]) +} + +fn narrow_table_header(app: &App, width: u16) -> Line<'static> { + Line::from(Span::styled( + truncate_string(" # Account / Status", width as usize), + app.theme.subtle_text_style(), + )) +} + +fn account_table_widths(width: u16) -> [Constraint; 8] { + if width >= 170 { + [ + Constraint::Length(3), + Constraint::Length(10), + Constraint::Length(30), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(10), + Constraint::Min(30), + Constraint::Length(24), + ] + } else { + [ + Constraint::Length(3), + Constraint::Length(8), + Constraint::Length(24), + Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(8), + Constraint::Min(24), + Constraint::Length(24), + ] + } +} + +fn narrow_table_row(app: &mut App, output: &UsageOutput, index: usize, area: Rect) -> Row<'static> { + let selected = app.selected_index == index; + let width = area.width as usize; + let row = usage_row_view(app, output); + let state = if width >= 70 { + account_readiness_label(output, row.readiness) + } else { + readiness_label(row.readiness).to_string() + }; + let state_width = if width >= 52 { + 14usize + } else if width >= 40 { + 10usize + } else { + 0usize + }; + let state_right_padding = usize::from(state_width > 0) * 2; + let left_width = width.saturating_sub(4 + state_width + state_right_padding); + let left = format!("{} {}", output.provider, row.account_summary); + let mut first = vec![ + styled( + format!(" {:<2} ", index + 1), + app.theme.secondary_text_style(), + selected, + ), + styled( + format!( + "{: 0 { + first.push(styled( + format!( + "{:>width$}", + truncate_string(&state, state_width), + width = state_width + ), + Style::default().fg(readiness_color(app, row.readiness)), + selected, + )); + first.push(styled( + " ".repeat(state_right_padding), + Style::default(), + selected, + )); + } + + let detail = if row.reset.is_empty() { + row.limit.clone() + } else { + format!("{} · {}", row.limit, row.reset) + }; + + let managed_label = if output.account.is_none() { + Some("Managed") + } else { + None + }; + let action_width = managed_label.map(str::len).unwrap_or(0); + let available_detail_width = width + .saturating_sub(4 + action_width + usize::from(action_width > 0)) + .max(8); + let detail_width = available_detail_width.min(if width >= 72 { 48 } else { 34 }); + let detail_style = row + .metric + .map(|metric| Style::default().fg(metric_color(app, metric))) + .unwrap_or_else(|| app.theme.secondary_text_style()); + let mut second = vec![ + styled(" ", Style::default(), selected), + styled( + truncate_string(&detail, detail_width.saturating_sub(1)), + detail_style, + selected, + ), + ]; + let used = Line::from(second.clone()).width(); + if action_width > 0 && area.width as usize > used + action_width { + second.push(styled(" ", Style::default(), selected)); + } + if let Some(label) = managed_label { + second.push(styled(label, app.theme.subtle_text_style(), selected)); + } + pad_selected_row(&mut second, area.width as usize, selected); + + Row::new([Cell::from(Text::from(vec![ + Line::from(first), + Line::from(second), + ]))]) + .style(account_table_row_style(app, index)) + .height(2) +} + +fn usage_row_view<'a>(app: &App, output: &'a UsageOutput) -> UsageRowView<'a> { + let account = account_name(app, output); + let metric = display_metric(output); + let plan = output + .plan + .as_deref() + .map(str::trim) + .filter(|plan| !plan.is_empty()) + .unwrap_or("Unknown") + .to_string(); + let limit = metric_summary(output); + let reset = metric.and_then(display_metric_reset).unwrap_or_default(); + let account_summary = account_plan_label(&account, output.plan.as_deref()); + + UsageRowView { + account, + account_summary, + plan, + limit, + reset, + readiness: readiness_status(output), + metric, + } +} + +fn metric_summary(output: &UsageOutput) -> String { + if output.metrics.is_empty() { + return "No limits".to_string(); + } + + let parts = output + .metrics + .iter() + .map(|metric| { + let label = compact_metric_label(&metric.label); + let label = truncate_string(&label, 14); + format!("{label} {:.0}%", metric.remaining_percent) + }) + .collect::>(); + parts.join(" · ") +} + +fn compact_metric_label(label: &str) -> String { + let mut value = label.trim().to_string(); + for prefix in [ + "GPT-5.3-Codex-", + "GPT-5.3-", + "Codex-", + "codex-", + "gpt-5.3-codex-", + "gpt-5.3-", + ] { + if let Some(stripped) = value.strip_prefix(prefix) { + value = stripped.to_string(); + break; + } + } + + value = value + .replace("Codex Spark", "Spark") + .replace("Codex-Spark", "Spark") + .replace("codex-spark", "Spark"); + + if value.eq_ignore_ascii_case("session") { + return "5h".to_string(); + } + if value.eq_ignore_ascii_case("spark") { + return "Spark".to_string(); + } + if value.eq_ignore_ascii_case("spark week") { + return "Spark weekly".to_string(); + } + value +} + +fn display_metric_reset(metric: &UsageMetric) -> Option { + metric + .resets_at + .as_ref() + .map(|reset| helpers::format_reset_time(reset)) +} + +fn account_table_row(app: &App, output: &UsageOutput, index: usize) -> Row<'static> { + let row = usage_row_view(app, output); + + let auth = account_auth_label(output); + let health = readiness_label(row.readiness); + let auth_color = account_auth_color(output); + let health_color = readiness_color(app, row.readiness); + let metric_color = row.metric.map(|metric| metric_color(app, metric)); + + Row::new([ + table_right_cell((index + 1).to_string(), app.theme.secondary_text_style()), + table_text_cell( + output.provider.clone(), + Style::default() + .fg(get_provider_shade(&output.provider, 0)) + .add_modifier(Modifier::BOLD), + ), + table_text_cell(row.account, app.theme.secondary_text_style()), + table_text_cell(row.plan, app.theme.secondary_text_style()), + table_text_cell(auth, Style::default().fg(auth_color)), + table_text_cell(health, Style::default().fg(health_color)), + table_text_cell( + row.limit, + metric_color + .map(|color| Style::default().fg(color)) + .unwrap_or_else(|| app.theme.secondary_text_style()), + ), + table_text_cell(row.reset, app.theme.subtle_text_style()), + ]) + .style(account_table_row_style(app, index)) + .height(1) +} + +fn pad_selected_row(spans: &mut Vec>, width: usize, selected: bool) { + let used = Line::from(spans.clone()).width(); + if used < width { + spans.push(styled(" ".repeat(width - used), Style::default(), selected)); + } +} + +fn table_text_cell(text: impl Into, style: Style) -> Cell<'static> { + Cell::from(Span::styled(text.into(), style)) +} + +fn table_right_cell(text: impl Into, style: Style) -> Cell<'static> { + Cell::from(Line::from(Span::styled(text.into(), style)).right_aligned()) +} + +fn account_table_row_style(app: &App, index: usize) -> Style { + if index % 2 == 1 { + app.theme.striped_row_style() + } else { + Style::default() + } +} + +fn use_account_button(account_id: &str) -> ButtonSpec { + ButtonSpec { + label: "Use Account".to_string(), + kind: ButtonKind::Primary, + action: ClickAction::CodexUseAccount { + account_id: account_id.to_string(), + }, + } +} + +fn remove_account_button(account_id: &str) -> ButtonSpec { + ButtonSpec { + label: "Remove".to_string(), + kind: ButtonKind::Danger, + action: ClickAction::CodexRemoveAccount { + account_id: account_id.to_string(), + }, + } +} + +fn reset_account_button(account_id: &str) -> ButtonSpec { + ButtonSpec { + label: "Reset".to_string(), + kind: ButtonKind::Warning, + action: ClickAction::CodexResetAccount { + account_id: account_id.to_string(), + }, + } +} + +fn group_outputs_by_provider(outputs: &[UsageOutput]) -> Vec> { + let mut groups: Vec> = Vec::new(); + + for (index, output) in outputs.iter().enumerate() { + if let Some(group) = groups + .iter_mut() + .find(|group| group.provider == output.provider) + { + group.outputs.push((index, output)); + } else { + groups.push(UsageProviderGroup { + provider: &output.provider, + outputs: vec![(index, output)], + }); + } + } + + groups +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum UsageReadiness { + Ready, + Watch, + Critical, + Unknown, +} + +impl UsageReadiness { + fn is_at_risk(self) -> bool { + matches!(self, UsageReadiness::Watch | UsageReadiness::Critical) + } +} + +fn readiness_status(output: &UsageOutput) -> UsageReadiness { + if output.metrics.is_empty() { + return UsageReadiness::Unknown; + } + + let lowest = output_score(output); + if lowest < 10.0 { + UsageReadiness::Critical + } else if lowest < 25.0 { + UsageReadiness::Watch + } else { + UsageReadiness::Ready + } +} + +fn overall_readiness(outputs: &[UsageOutput]) -> UsageReadiness { + if outputs.is_empty() { + return UsageReadiness::Unknown; + } + + if let Some(active) = active_output(outputs) { + let active_status = readiness_status(active); + if active_status.is_at_risk() { + return active_status; + } + } + + if outputs.iter().any(|output| { + matches!( + readiness_status(output), + UsageReadiness::Critical | UsageReadiness::Watch + ) + }) { + UsageReadiness::Watch + } else if outputs + .iter() + .any(|output| readiness_status(output) == UsageReadiness::Ready) + { + UsageReadiness::Ready + } else { + UsageReadiness::Unknown + } +} + +fn overall_state_label(outputs: &[UsageOutput]) -> &'static str { + if let Some(active) = active_output(outputs) { + if readiness_status(active) == UsageReadiness::Critical + && best_fallback_output(outputs).is_some() + { + return "Switch recommended"; + } + } + + match overall_readiness(outputs) { + UsageReadiness::Ready => "Ready", + UsageReadiness::Watch => "Ready with warnings", + UsageReadiness::Critical => "Quota low", + UsageReadiness::Unknown => "Unknown", + } +} + +fn readiness_label(status: UsageReadiness) -> &'static str { + match status { + UsageReadiness::Ready => "Ready", + UsageReadiness::Watch => "Watch", + UsageReadiness::Critical => "Quota Low", + UsageReadiness::Unknown => "Unknown", + } +} + +fn readiness_color(app: &App, status: UsageReadiness) -> Color { + match status { + UsageReadiness::Ready => app.theme.accent, + UsageReadiness::Watch => Color::Yellow, + UsageReadiness::Critical => Color::Red, + UsageReadiness::Unknown => app.theme.muted, + } +} + +fn active_output(outputs: &[UsageOutput]) -> Option<&UsageOutput> { + outputs.iter().find(|output| { + output + .account + .as_ref() + .is_some_and(|account| account.is_active) + }) +} + +fn best_fallback_output(outputs: &[UsageOutput]) -> Option<&UsageOutput> { + outputs + .iter() + .filter(|output| { + !output + .account + .as_ref() + .is_some_and(|account| account.is_active) + && readiness_status(output) == UsageReadiness::Ready + }) + .max_by(|a, b| output_score(a).total_cmp(&output_score(b))) +} + +fn output_score(output: &UsageOutput) -> f64 { + output + .metrics + .iter() + .map(|metric| metric.remaining_percent) + .min_by(|a, b| a.total_cmp(b)) + .unwrap_or(0.0) +} + +fn display_metric(output: &UsageOutput) -> Option<&UsageMetric> { + if output + .metrics + .iter() + .any(|metric| metric.remaining_percent < 25.0) + { + output + .metrics + .iter() + .min_by(|a, b| a.remaining_percent.total_cmp(&b.remaining_percent)) + } else { + output.metrics.first() + } +} + +fn next_reset_label(app: &App, outputs: &[UsageOutput]) -> Option { + if let Some(active) = active_output(outputs) { + if let Some(label) = output_reset_label(app, active) { + return Some(label); + } + } + + outputs + .iter() + .find_map(|output| output_reset_label(app, output)) +} + +fn output_reset_label(app: &App, output: &UsageOutput) -> Option { + let metric = display_metric(output)?; + let reset = metric.resets_at.as_ref()?; + Some(format!( + "{} · {}", + truncate_string(&account_name(app, output), 22), + helpers::format_reset_time(reset) + )) +} + +fn overall_action(app: &App, outputs: &[UsageOutput]) -> String { + let Some(active) = active_output(outputs) else { + return "Choose an active account".to_string(); + }; + + match readiness_status(active) { + UsageReadiness::Ready => { + if outputs + .iter() + .any(|output| readiness_status(output) == UsageReadiness::Unknown) + { + "Refresh accounts with unknown limits".to_string() + } else { + "Keep current account".to_string() + } + } + UsageReadiness::Watch => "Monitor active quota".to_string(), + UsageReadiness::Critical => best_fallback_output(outputs) + .map(|fallback| format!("Use {}", account_name(app, fallback))) + .unwrap_or_else(|| "Wait for reset or refresh".to_string()), + UsageReadiness::Unknown => "Refresh active account".to_string(), + } +} + +fn output_display_name(app: &App, output: &UsageOutput) -> String { + match &output.account { + Some(_) => format!("{} ({})", output.provider, account_name(app, output)), + None => { + if output.email.is_some() { + format!("{} ({})", output.provider, account_name(app, output)) + } else { + output.provider.clone() + } + } + } +} + +fn account_name(app: &App, output: &UsageOutput) -> String { + if app.hide_usage_emails { + if let Some(account) = &output.account { + if let Some(label) = account + .label_name() + .filter(|label| !looks_like_email(label)) + { + return label.to_string(); + } + return format!("Account {}", account.short_id()); + } + + if output.email.as_deref().is_some_and(looks_like_email) { + return "[hidden email]".to_string(); + } + } + + output + .account_display_name() + .or_else(|| output.email.clone()) + .map(|value| privacy_text(app, &value)) + .unwrap_or_else(|| output.provider.clone()) +} + +fn email_display(app: &App, email: Option<&str>) -> String { + match email { + Some(email) => privacy_text(app, email), + None => "Unknown".to_string(), + } +} + +fn privacy_text(app: &App, value: &str) -> String { + if app.hide_usage_emails && looks_like_email(value) { + "[hidden email]".to_string() + } else { + value.to_string() + } +} + +fn account_state_label(output: &UsageOutput) -> String { + match &output.account { + Some(account) if account.is_active => "Active".to_string(), + Some(_) => "Saved".to_string(), + None if output.metrics.iter().any(|m| m.remaining_percent < 25.0) => { + "Quota low".to_string() + } + None => "Authenticated".to_string(), + } +} + +fn account_auth_label(output: &UsageOutput) -> &'static str { + match &output.account { + Some(account) if account.is_active => "Active", + Some(_) => "Saved", + None => "Managed", + } +} + +fn account_auth_color(output: &UsageOutput) -> Color { + match &output.account { + Some(account) if account.is_active => Color::Green, + Some(_) => Color::Blue, + None => Color::Yellow, + } +} + +fn remaining_label(metric: &UsageMetric) -> String { + metric + .remaining_label + .clone() + .unwrap_or_else(|| format!("{:.0}% left", metric.remaining_percent)) +} + +fn has_available_reset_credit(output: &UsageOutput) -> bool { + output.provider == "Codex" + && output + .reset_credits + .as_ref() + .is_some_and(|credits| credits.available_count > 0) +} + +fn reset_credit_count_label(count: u32) -> String { + if count == 1 { + "1 available".to_string() + } else { + format!("{count} available") + } +} + +fn reset_bank_summary(outputs: &[UsageOutput]) -> String { + let available: u32 = outputs + .iter() + .filter(|output| output.provider == "Codex") + .filter_map(|output| output.reset_credits.as_ref()) + .map(|credits| credits.available_count) + .sum(); + if available == 0 { + return "No reset credits".to_string(); + } + + let expiries = credit_expiry_buckets( + outputs + .iter() + .filter_map(|output| output.reset_credits.as_ref()) + .flat_map(|credits| credits.credits.iter()) + .filter_map(|credit| credit.expires_at.as_deref()), + ); + + let count = if available == 1 { + reset_credit_count_label(available) + } else { + format!("{available} available across accounts") + }; + match nearest_credit_expiry_label(&expiries) { + Some(nearest) => format!("{count} · {nearest}"), + None => count, + } +} + +fn credits_status_line(output: &UsageOutput) -> Option { + let mut parts = Vec::new(); + if let Some(credits) = &output.credit_status { + if let Some(balance) = credits.balance.as_deref() { + parts.push(format!("API credits {balance}")); + } + if credits.unlimited == Some(true) { + parts.push("unlimited".to_string()); + } + if credits.has_credits == Some(false) { + parts.push("no API credits".to_string()); + } + if credits.overage_limit_reached == Some(true) { + parts.push("API overage reached".to_string()); + } + } + if let Some(control) = &output.spend_control { + if control.reached == Some(true) { + parts.push("spend limit reached".to_string()); + } else if control.reached == Some(false) { + parts.push("spend OK".to_string()); + } + if let Some(limit) = control.individual_limit.as_deref() { + parts.push(format!("spend limit {limit}")); + } + } + if parts.is_empty() { + None + } else { + Some(parts.join(" · ")) + } +} + +fn format_expiry_time(value: &str) -> String { + helpers::format_reset_time(value).replace("resets", "expires") +} + +fn metric_color(app: &App, metric: &UsageMetric) -> Color { + if metric.remaining_percent < 10.0 { + Color::Red + } else if metric.remaining_percent < 25.0 { + Color::Yellow + } else { + app.theme.accent + } +} + +fn quota_bar_spans( + remaining_percent: f64, + width: usize, + color: Color, + app: &App, +) -> Vec> { + light_ratio_bar_spans( + remaining_percent / 100.0, + width, + Style::default().fg(color), + app.theme.subtle_text_style(), + ) +} + +fn styled>(text: T, style: Style, selected: bool) -> Span<'static> { + let style = if selected { + style.bg(Color::Blue).fg(Color::White) + } else { + style + }; + Span::styled(text.into(), style) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::usage::{ + UsageAccount, UsageCreditStatus, UsageFetchDiagnostic, UsageFetchDiagnosticKind, + UsageFetchDiagnosticSeverity, UsageResetCredit, UsageResetCredits, UsageSpendControl, + }; + use crate::tui::app::{Tab, TuiConfig}; + use crate::tui::data::UsageData; + use chrono::{Duration, Utc}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{backend::TestBackend, Terminal}; + + fn output(provider: &str, account: Option) -> UsageOutput { + UsageOutput { + provider: provider.to_string(), + account, + plan: Some("Pro".to_string()), + email: Some("user@example.com".to_string()), + metrics: vec![UsageMetric { + label: "Session".to_string(), + used_percent: 10.0, + remaining_percent: 90.0, + remaining_label: Some("90% left".to_string()), + resets_at: None, + }], + reset_credits: None, + credit_status: None, + spend_control: None, + } + } + + fn output_with_remaining( + provider: &str, + account: Option, + remaining_percent: f64, + ) -> UsageOutput { + let mut output = output(provider, account); + output.metrics[0].remaining_percent = remaining_percent; + output.metrics[0].remaining_label = Some(format!("{remaining_percent:.0}% left")); + output + } + + /// An RFC3339 instant far enough ahead of "now" that + /// `helpers::format_reset_time` always takes the absolute-date branch + /// (`resets