Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
581cae0
feat: add subscription usage command with Claude, Codex, and Z.ai pro…
shidevil May 1, 2026
d1cba85
feat(usage): add Amp, Copilot, Kimi, MiniMax providers; refactor to m…
shidevil May 1, 2026
f1ca00d
fix(minimax): correct usage_count inversion and match openusage behavior
shidevil May 2, 2026
aca3b06
fix(kimi): persist rotated refresh token and expires_at to disk
shidevil May 2, 2026
8884232
fix(copilot): respect GH_CONFIG_DIR for gh hosts path resolution
shidevil May 2, 2026
d8ebd07
fix(usage): reject unsupported --home flag instead of silently ignori…
shidevil May 2, 2026
0f6f567
fix(codex): require access_token in credential lookup and add CODEX_H…
shidevil May 2, 2026
2035789
fix: batch-fix six remaining usage-provider issues
shidevil May 2, 2026
122c12a
perf(usage): skip providers without credentials and fetch in parallel
shidevil May 2, 2026
2dcc24f
feat(usage): cache subscription data to disk with 5-minute TTL
shidevil May 2, 2026
b8c7acb
fix(zai): order metrics as Session/Weekly/Web Search and widen label …
shidevil May 2, 2026
3369e6a
fix(usage): batch reliability and security fixes
shidevil May 3, 2026
9609d38
fix(copilot): support Windows and XDG config directories for gh hosts
shidevil May 3, 2026
8a24115
fix(usage): batch 2 — security, correctness, and UX fixes
shidevil May 3, 2026
01160fd
fix(tui): run usage fetch in background thread to prevent UI freeze
shidevil May 3, 2026
1432448
fix(usage): runtime, credential fallback, and concurrency fixes
shidevil May 3, 2026
3c3e1c1
fix(kimi): update stored refresh token after proactive refresh
shidevil May 3, 2026
430ca9b
fix(usage): kimi labels, dedup, disconnected handling, copilot fallback
shidevil May 3, 2026
dcf8c25
fix(tui): 'r' refreshes all tabs including usage
shidevil May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 86 additions & 0 deletions PR_BODY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
## Summary

Adds a `tokscale usage` CLI command and TUI **Usage** tab that displays live subscription quota and remaining usage for AI coding assistants.

### Quick start

```bash
tokscale usage # light-mode card output
tokscale usage --json # JSON for scripting
tokscale tui # switch to Usage tab (2nd tab)
```

## Supported Providers (7)

| Provider | Auth Source | Metrics |
|---|---|---|
| **Claude** | `~/.claude/.credentials.json` / macOS Keychain | Session (5h), Weekly (7d), Opus (7d) |
| **Codex** | `CODEX_HOME/auth.json`, `~/.config/codex/auth.json`, `~/.codex/auth.json`, macOS Keychain | Session (5h), Weekly (7d) |
| **Z.ai** | `ZAI_API_KEY` / `GLM_API_KEY` env var | Session, Weekly, Web Search |
| **Amp** | `~/.local/share/amp/secrets.json` | Free tier ($remaining/$total), Credits |
| **GitHub Copilot** | macOS Keychain `gh:github.com`, `hosts.yml` (respects `GH_CONFIG_DIR`) | Premium, Chat, Completions (paid + free) |
| **Kimi Code** | `~/.kimi/credentials/kimi-code.json` | Session, Weekly |
| **MiniMax** | `MINIMAX_API_KEY` / `MINIMAX_API_TOKEN` env var | Session (prompts) |

Only providers with valid credentials are queried — the rest are silently skipped.

## Architecture

Refactored `commands/usage.rs` into a `commands/usage/` module directory:

```
commands/usage/
├── mod.rs # Shared types, fetch_all(), disk cache, CLI rendering
├── helpers.rs # capitalize(), format_reset_time(), read_keychain(), render_ascii_bar()
├── claude.rs # Claude OAuth provider
├── codex.rs # Codex/OpenAI provider
├── zai.rs # Z.ai provider
├── amp.rs # Amp provider
├── copilot.rs # GitHub Copilot provider
├── kimi.rs # Kimi Code provider
└── minimax.rs # MiniMax provider
```

Each provider exports `has_credentials() -> bool` and `fetch() -> Result<UsageOutput>`.

## Performance

- **Credential pre-check**: Fast local file/env checks skip providers without credentials entirely (no network calls)
- **Parallel fetching**: Active providers run concurrently via `std::thread::scope`
- **Disk cache**: Data cached to `~/.cache/tokscale/subscription-usage-cache.json` with 5-minute TTL — the Usage tab loads instantly on startup like other tabs
- **No new dependencies**: Uses only crates already in the workspace (`reqwest`, `serde`, `serde_json`, `chrono`, `anyhow`, `dirs`, `tokio`)

## Bug fixes included

- **MiniMax**: `current_interval_usage_count` is a remaining count despite its name — now handled correctly with `current_interval_used_count` preferred when available
- **Kimi**: OAuth refresh tokens (which rotate) are now persisted back to disk after each refresh, preventing stale tokens on next run
- **Codex**: Credential lookup now requires `tokens.access_token` to be present (not just the `tokens` object), supports `CODEX_HOME` env var and macOS Keychain fallback
- **Copilot**: Respects `GH_CONFIG_DIR` env var for hosts.yml path; YAML parser correctly handles other fields appearing before `oauth_token` under `github.com:`
- **OAuth payloads**: Kimi and Codex refresh payloads use `reqwest .form()` for proper URL encoding of tokens that may contain reserved characters
- **Platform compat**: `read_keychain()` returns a clean error on non-macOS instead of spawning a missing binary
- **TUI**: Empty usage state shows a proper message instead of the loading prompt; tab navigation tests updated for 7-tab layout
- **Z.ai**: Metrics ordered as Session → Weekly → Web Search regardless of API response order; renamed Monthly→Weekly, Web Searches→Web Search

## Files changed

- **New**: `commands/usage/` directory (9 provider + helper files, ~1700 lines)
- **New**: `tui/ui/usage.rs` (TUI rendering for Usage tab)
- **Modified**: `tui/app.rs` (Usage tab, disk cache, updated tab tests)
- **Modified**: `main.rs` (Usage subcommand + --home rejection)
- **Modified**: `README.md` (provider docs, corrected tab count)
- **Unchanged**: `Cargo.lock` (no new dependencies)

## Test plan

- [x] `cargo build --release` compiles cleanly with no warnings
- [x] All 451 unit tests pass (including updated tab navigation tests)
- [x] `tokscale usage --json` outputs valid JSON
- [x] `tokscale usage` renders light-mode cards with aligned columns
- [x] TUI Usage tab renders quota bars with cache + live refresh
- [x] Switching tabs is instant after first fetch (disk cache works)
- [x] Providers without credentials are silently skipped (no error spam)
- [x] `tokscale --home /tmp usage` produces clear error

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
52 changes: 50 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ In the age of AI-assisted development, **tokens are the new energy**. They power
- [Social](#social)
- [Cursor IDE Commands](#cursor-ide-commands)
- [Antigravity Commands](#antigravity-commands)
- [Subscription Usage](#subscription-usage)
- [Example Output](#example-output---light-version)
- [Configuration](#configuration)
- [Environment Variables](#environment-variables)
Expand Down Expand Up @@ -242,9 +243,9 @@ tokscale models --json > report.json # Save to file

The interactive TUI mode provides:

- **6 Views**: Overview (chart + top models), Models, Daily, Hourly, Stats (contribution graph), Agents
- **7 Views**: Overview (chart + top models), Usage (subscription quotas), Models, Daily, Hourly, Stats (contribution graph), Agents
- **Keyboard Navigation**:
- `1-6` or `←/→/Tab`: Switch views
- `←/→/Tab`: Switch views
- `↑/↓`: Navigate lists
- `c/d/t`: Sort by cost/date/tokens
- `s`: Open source picker dialog
Expand Down Expand Up @@ -469,6 +470,53 @@ tokscale antigravity purge-cache

**How it works**: `tokscale antigravity sync` discovers local Antigravity session candidates, fetches confirmed usage data from the local language server RPC, and stores normalized JSONL artifacts for tokscale-core to parse later. Run sync before reports if you want the freshest Antigravity data.

### 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.

```bash
# Show subscription usage for all detected providers
tokscale usage

# Output as JSON (for scripting)
tokscale usage --json

# Lightweight terminal output (no TUI)
tokscale usage --light
```

In the TUI, navigate to the **Usage** tab to see subscription data. Press `u` or `r` to refresh.

#### Supported Providers

| 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 |
| **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` |
| **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` |

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.

#### Example Output

```
╭──────────────────────────────────────────────────────────╮
│ 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 │
╰──────────────────────────────────────────────────────────╯
```

### Example Output (`--light` version)

<img alt="CLI Light" src="./.github/assets/cli-light.png" />
Expand Down
1 change: 1 addition & 0 deletions crates/tokscale-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod usage;
pub mod wrapped;
170 changes: 170 additions & 0 deletions crates/tokscale-cli/src/commands/usage/amp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
use anyhow::Result;
use serde::Deserialize;

use super::{UsageMetric, UsageOutput};

#[derive(Debug, Deserialize)]
struct Secrets {
#[serde(rename = "apiKey@https://ampcode.com/")]
api_key: Option<String>,
}

#[derive(Debug, Deserialize)]
struct ApiResponse {
#[allow(dead_code)]
ok: Option<bool>,
result: Option<ApiResult>,
}

#[derive(Debug, Deserialize)]
struct ApiResult {
display_text: Option<String>,
}

fn read_credentials() -> Result<String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
let path = home.join(".local").join("share").join("amp").join("secrets.json");
if !path.exists() {
anyhow::bail!("No Amp credentials found. Run 'amp' to log in.");
}
let content = std::fs::read_to_string(&path)?;
let secrets: Secrets = serde_json::from_str(&content)?;
secrets
.api_key
.ok_or_else(|| anyhow::anyhow!("No Amp API key in secrets.json"))
}

/// 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<f64> {
let start = text.find(prefix)? + prefix.len();
let rest = &text[start..];
let end = rest
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != ',')
.unwrap_or(rest.len());
let num_str = &rest[..end];
num_str.replace(',', "").parse().ok()
}

fn parse_display_text(text: &str) -> Vec<UsageMetric> {
let mut metrics = Vec::new();

// 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::<f64>() {
// 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::<f64>() {
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,
});
}
}
}
}
}
}

// Parse credits: "Individual credits: $X remaining"
if let Some(credits) = parse_dollar_after(text, "Individual credits: $") {
metrics.push(UsageMetric {
label: "Credits".into(),
used_percent: 0.0,
remaining_percent: 100.0,
remaining_label: Some(format!("${credits:.2} left")),
resets_at: None,
});
}

metrics
}

fn detect_plan(metrics: &[UsageMetric]) -> Option<String> {
let has_free = metrics.iter().any(|m| m.label == "Free");
let has_credits = metrics.iter().any(|m| m.label == "Credits");
match (has_free, has_credits) {
(true, _) => Some("Free".into()),
(false, true) => Some("Credits".into()),
_ => None,
}
}

pub fn has_credentials() -> bool {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
home.join(".local").join("share").join("amp").join("secrets.json").exists()
}

pub fn fetch() -> Result<UsageOutput> {
let api_key = read_credentials()?;

let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async {
let client = reqwest::Client::new();
let resp = client
.post("https://ampcode.com/api/internal")
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"method": "userDisplayBalanceInfo",
"params": {}
}))
.send()
.await?;

if !resp.status().is_success() {
anyhow::bail!("Amp usage request failed (HTTP {})", resp.status());
}

let body: ApiResponse = resp.json().await?;
if body.ok == Some(false) {
let msg = body
.result
.as_ref()
.and_then(|r| r.display_text.as_deref())
.unwrap_or("unknown error");
anyhow::bail!("Amp API returned an error: {msg}");
}
let display_text = body
.result
.and_then(|r| r.display_text)
.unwrap_or_default();

let metrics = parse_display_text(&display_text);
let plan = detect_plan(&metrics);

Ok(UsageOutput {
provider: "Amp".into(),
plan,
email: None,
metrics,
})
})
}
Loading