diff --git a/.env.example b/.env.example index 431e933b..a2df7665 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -# Tauri updater signing -TAURI_SIGNING_PRIVATE_KEY=/path/to/your.key +# Tauri updater signing (private key is gitignored at .tauri/crossusage.key) +TAURI_SIGNING_PRIVATE_KEY_PATH=.tauri/crossusage.key TAURI_SIGNING_PRIVATE_KEY_PASSWORD= # macOS code signing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b454598c..b5827a2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: - uses: dtolnay/rust-toolchain@stable + - name: Install Linux build dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config + - name: Cargo test (crossusage-cli) run: cargo test -p crossusage-cli --locked diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b85227c..b9f7bd28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ **CrossUsage** ships **1.x** releases from [github.com/barramee27/crossusage](https://github.com/barramee27/crossusage). Older **0.6.x** sections below are **archived OpenUsage upstream** notes, not CrossUsage release numbers. +## 1.1.0 + +**Theme:** Know before you run out — local-only insights on probe + history data. + +### New features (fork) + +- **Usage insights banner** on the home panel: behind-pace providers, tightest remaining quota, and next reset. +- **Pace burn-rate alerts** for the primary progress line (low remaining % and projected to run out before reset). +- **Rolling 7-day rollup** of tokens and estimated spend from local `usage_daily` history. +- **Cursor MTD usage** line from dashboard CSV export on the Cursor card (45-minute in-memory cache). +- **Insights banner v2:** click insight → open provider, dismiss rows, split token/cost deltas, 30-day rollup, 7-day sparkline. +- **History export** (Settings) and **local API** `GET /v1/history/quota` + `GET /v1/history/daily`. +- **Cursor billing table** on provider detail (GUI parity with `crossusage-cli usage-stats`). +- **Spend spike alert** when 7-day estimated local spend jumps vs prior week (opt-in). +- **Cursor Nightly** as a separate provider (`cursor-nightly`) with its own install path and icon. +- **Provider list** sorted A–Z by display name in Settings and navigation. + +### Ports from [OpenUsage v0.6.27](https://github.com/robinebers/openusage/releases/tag/v0.6.27) + +- **Devin:** support auth from the Devin - Next app ([#554](https://github.com/robinebers/openusage/pull/554)). +- **macOS panel:** clamp to visible screen when menu bar auto-hides ([#557](https://github.com/robinebers/openusage/pull/557)). +- **macOS keychain:** allow `readGenericPassword(service)` without account arg ([#559](https://github.com/robinebers/openusage/pull/559)). +- **Plugins:** remove retired Windsurf plugin from app data on startup ([#552](https://github.com/robinebers/openusage/pull/552)). + +--- + ## 1.0.11 Ports **[OpenUsage v0.6.26](https://github.com/robinebers/openusage/releases/tag/v0.6.26)** while keeping CrossUsage fork UI (Linux/Windows native window, usage history, multi-account). diff --git a/Cargo.lock b/Cargo.lock index 56e2c0e1..1f982e06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1199,7 +1199,7 @@ dependencies = [ [[package]] name = "crossusage" -version = "1.0.11" +version = "1.1.0" dependencies = [ "base64 0.22.1", "crossusage-core", @@ -1222,6 +1222,8 @@ dependencies = [ "tauri-plugin-aptabase", "tauri-plugin-autostart", "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-global-shortcut", "tauri-plugin-liquid-glass", "tauri-plugin-log", @@ -1239,7 +1241,7 @@ dependencies = [ [[package]] name = "crossusage-cli" -version = "1.0.11" +version = "1.1.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -1271,10 +1273,13 @@ dependencies = [ [[package]] name = "crossusage-core" -version = "1.0.11" +version = "1.1.0" dependencies = [ "aes-gcm", + "anyhow", "base64 0.22.1", + "chrono", + "csv", "dirs 6.0.0", "keyring", "libc", @@ -1285,6 +1290,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serial_test", "sha2 0.11.0", "tempfile", "time", @@ -4899,6 +4905,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -6142,6 +6172,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation 0.3.2", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.2" diff --git a/README.md b/README.md index 0c65e461..51571797 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Linux **CLI-only** without a desktop package: add `INSTALL_MODE=cli` to the firs - Global shortcut to show/hide the panel - Plugin-based providers (ship updates without rebuilding core logic) - Optional **local usage history** (Settings → *Usage history*, SQLite on disk only) +- **Usage insights** on the home panel (pace warnings, tightest quota, next reset, 7-day rollup when history is enabled) +- **Pace burn-rate alerts** for primary quota lines (Settings → Usage Alerts) +- **Cursor billing usage table** on provider detail (`usage-stats` / cstats parity) +- **History CSV export** and `GET /v1/history/*` on the local API (with usage history enabled) - **[Local HTTP API](docs/local-http-api.md)** on `127.0.0.1:6736` while the app runs - Optional **[HTTP/SOCKS proxy](docs/proxy.md)** via `~/.crossusage/config.json` @@ -69,6 +73,7 @@ Multi-account OAuth rows: **[multi-account credentials](docs/providers/multi-acc | Copilot | [copilot](docs/providers/copilot.md) | | CrofAI | [crofai](docs/providers/crofai.md) | | Cursor | [cursor](docs/providers/cursor.md) | +| Cursor Nightly | [cursor](docs/providers/cursor.md) (separate install at `~/.config/Cursor Nightly` on Linux) | | DeepSeek | [deepseek](docs/providers/deepseek.md) | | Factory / Droid | [factory](docs/providers/factory.md) | | Fireworks AI | [fireworks-ai](docs/providers/fireworks-ai.md) | diff --git a/bun.lock b/bun.lock index 323881a8..13010774 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,8 @@ "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", @@ -226,6 +228,10 @@ "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="], + + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.5.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ=="], + "@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="], "@tauri-apps/plugin-log": ["@tauri-apps/plugin-log@2.8.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw=="], diff --git a/copy-bundled.cjs b/copy-bundled.cjs index dd0850ff..71d0e54c 100644 --- a/copy-bundled.cjs +++ b/copy-bundled.cjs @@ -1,4 +1,4 @@ -const { cpSync, readdirSync, rmSync } = require("fs") +const { cpSync, readFileSync, readdirSync, rmSync, writeFileSync } = require("fs") const { join } = require("path") const root = __dirname @@ -12,6 +12,17 @@ const plugins = readdirSync(srcDir, { withFileTypes: true }) .filter((d) => d.isDirectory() && !exclude.has(d.name)) .map((d) => d.name) +function buildCursorNightlyPlugin() { + const nightlyDir = join(srcDir, "cursor-nightly") + const cursorJs = readFileSync(join(srcDir, "cursor", "plugin.js"), "utf8") + writeFileSync( + join(nightlyDir, "plugin.js"), + `globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly";\n${cursorJs}`, + ) +} + +buildCursorNightlyPlugin() + for (const id of plugins) { cpSync(join(srcDir, id), join(dstDir, id), { recursive: true }) } diff --git a/crates/crossusage-cli/Cargo.toml b/crates/crossusage-cli/Cargo.toml index a49b234f..3a528f0c 100644 --- a/crates/crossusage-cli/Cargo.toml +++ b/crates/crossusage-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage-cli" -version = "1.0.11" +version = "1.1.0" description = "CrossUsage CLI — terminal interface for the same plugin engine as the GUI" edition = "2021" diff --git a/crates/crossusage-cli/src/cursor_token_usage.rs b/crates/crossusage-cli/src/cursor_token_usage.rs index 06fc26ad..538d5260 100644 --- a/crates/crossusage-cli/src/cursor_token_usage.rs +++ b/crates/crossusage-cli/src/cursor_token_usage.rs @@ -1,35 +1,18 @@ -//! Cursor token-level usage from the same CSV export as [cstats](https://github.com/robinebers/cstats). -//! Other providers do not expose this export; use `list` / `probe` for subscription meters. +//! CLI presentation for Cursor CSV usage export (logic in crossusage-core). -use crate::cli_width; -use anyhow::{bail, Context, Result}; -use base64::Engine; -use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Utc}; -use rusqlite::{Connection, OpenFlags}; -use serde::Deserialize; +use anyhow::{bail, Result}; +use crossusage_core::cursor_usage_export::{ + aggregate_by_model, aggregate_by_provider, download_cursor_usage_csv, parse_usage_csv, resolve_date_range, + to_epoch_range_ms, RowAgg, +}; use serde_json::json; use std::collections::HashMap; -use std::path::PathBuf; -use std::time::Duration; use tabled::settings::Style; use tabled::{Table, Tabled}; -const REFRESH_URL: &str = "https://api2.cursor.sh/oauth/token"; -const CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"; -const EXPORT_URL: &str = "https://cursor.com/api/dashboard/export-usage-events-csv"; -const ACCESS_KEY: &str = "cursorAuth/accessToken"; -const REFRESH_KEY: &str = "cursorAuth/refreshToken"; -const REFRESH_BUFFER_MS: i64 = 5 * 60 * 1000; - -#[derive(Debug, Clone, Default)] -struct RowAgg { - input_no_cache: u64, - input_cache_write: u64, - cache_read: u64, - output: u64, - total_tokens: u64, - cost_usd: f64, -} +pub use crossusage_core::cursor_usage_export::{ + fetch_cursor_month_to_date_totals_for_plugin, format_token_count, +}; #[derive(Debug, Clone, Tabled)] struct SummaryModelRow { @@ -65,7 +48,6 @@ struct SummaryProviderRow { cost_usd: String, } -/// CLI args mirror [cstats](https://github.com/robinebers/cstats) where possible. pub struct UsageStatsArgs { pub provider: String, pub since: Option, @@ -179,204 +161,6 @@ fn totals_json(m: &HashMap) -> serde_json::Value { }) } -#[derive(Debug, Clone)] -struct CsvUsageRow { - model: String, - input_cache_write: u64, - input_no_cache: u64, - cache_read: u64, - output_tokens: u64, - total_tokens: u64, - cost_usd: f64, -} - -fn parse_int_cell(s: &str) -> u64 { - let t = s.trim(); - if t.is_empty() { - return 0; - } - let digits: String = t.chars().filter(|c| c.is_ascii_digit()).collect(); - digits.parse().unwrap_or(0) -} - -fn parse_cost_cell(s: &str) -> f64 { - let t = s.trim().trim_start_matches('$').replace(',', ""); - t.parse().unwrap_or(0.0) -} - -fn csv_date_to_yyyymmdd(raw: &str) -> Result { - let raw = raw.trim(); - if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { - let local = dt.with_timezone(&Local); - return Ok(format!( - "{:04}{:02}{:02}", - local.year(), - local.month(), - local.day() - )); - } - if let Ok(nd) = NaiveDate::parse_from_str( - raw.split('T').next().unwrap_or(raw), - "%Y-%m-%d", - ) { - return Ok(format!( - "{:04}{:02}{:02}", - nd.year(), - nd.month(), - nd.day() - )); - } - if raw.len() >= 10 && raw.as_bytes()[4] == b'-' && raw.as_bytes()[7] == b'-' { - if let Ok(nd) = NaiveDate::parse_from_str(&raw[..10], "%Y-%m-%d") { - return Ok(format!( - "{:04}{:02}{:02}", - nd.year(), - nd.month(), - nd.day() - )); - } - } - bail!("Unrecognized CSV date: {raw:?}") -} - -fn row_in_range(date_yyyymmdd: &str, since: &str, until: &str) -> bool { - date_yyyymmdd >= since && date_yyyymmdd <= until -} - -fn parse_usage_csv(text: &str, since: &str, until: &str) -> Result> { - let mut rdr = csv::ReaderBuilder::new() - .has_headers(true) - .from_reader(text.as_bytes()); - - let headers = rdr.headers()?.clone(); - let required = [ - "Date", - "Model", - "Input (w/ Cache Write)", - "Input (w/o Cache Write)", - "Cache Read", - "Output Tokens", - "Total Tokens", - "Cost", - ]; - for h in required { - if !headers.iter().any(|x| x == h) { - bail!("Cursor CSV missing column {h:?}. Export format may have changed."); - } - } - - let col = |name: &str| -> Result { - headers - .iter() - .position(|h| h == name) - .with_context(|| format!("missing column {name}")) - }; - let i_date = col("Date")?; - let i_model = col("Model")?; - - let mut out = Vec::new(); - for rec in rdr.records() { - let rec = rec?; - let date_raw = rec.get(i_date).unwrap_or(""); - let date_yyyymmdd = csv_date_to_yyyymmdd(date_raw)?; - if !row_in_range(&date_yyyymmdd, since, until) { - continue; - } - let get = |name: &str| -> Result<&str> { - let i = headers - .iter() - .position(|h| h == name) - .with_context(|| format!("missing column {name}"))?; - Ok(rec.get(i).unwrap_or("")) - }; - let model = rec.get(i_model).unwrap_or("").trim().to_string(); - if model.is_empty() { - continue; - } - let input_cache_write = parse_int_cell(get("Input (w/ Cache Write)")?); - let input_no_cache = parse_int_cell(get("Input (w/o Cache Write)")?); - let cache_read = parse_int_cell(get("Cache Read")?); - let output_tokens = parse_int_cell(get("Output Tokens")?); - let total_tokens = parse_int_cell(get("Total Tokens")?); - let cost_usd = parse_cost_cell(get("Cost")?); - - let sum_tokens = input_cache_write + input_no_cache + cache_read + output_tokens; - if input_no_cache == 0 - && output_tokens == 0 - && input_cache_write == 0 - && cache_read == 0 - && total_tokens == 0 - && cost_usd == 0.0 - { - continue; - } - - out.push(CsvUsageRow { - model, - input_cache_write, - input_no_cache, - cache_read, - output_tokens, - total_tokens: if total_tokens > 0 { - total_tokens - } else { - sum_tokens - }, - cost_usd, - }); - } - Ok(out) -} - -fn aggregate_by_model(rows: &[CsvUsageRow]) -> HashMap { - let mut m: HashMap = HashMap::new(); - for row in rows { - let e = m.entry(row.model.clone()).or_default(); - e.input_no_cache += row.input_no_cache; - e.input_cache_write += row.input_cache_write; - e.cache_read += row.cache_read; - e.output += row.output_tokens; - e.total_tokens += row.total_tokens; - e.cost_usd += row.cost_usd; - } - m -} - -fn infer_provider(model: &str) -> String { - let s = model.to_lowercase(); - if s.contains("claude") { - return "anthropic".into(); - } - if s.contains("gemini") || s.contains("google") { - return "google".into(); - } - if s.contains("gpt") || s.contains("openai") { - return "openai".into(); - } - if s.contains("composer") || s.contains("cursor") || s.contains("kimi") { - return "cursor".into(); - } - if s.contains("deepseek") { - return "deepseek".into(); - } - "other".into() -} - -fn aggregate_by_provider(rows: &[CsvUsageRow]) -> HashMap { - let mut m: HashMap = HashMap::new(); - for row in rows { - let p = infer_provider(&row.model); - let e = m.entry(p).or_default(); - e.input_no_cache += row.input_no_cache; - e.input_cache_write += row.input_cache_write; - e.cache_read += row.cache_read; - e.output += row.output_tokens; - e.total_tokens += row.total_tokens; - e.cost_usd += row.cost_usd; - } - m -} - fn fmt_num(n: u64) -> String { let s = n.to_string(); let mut out = String::new(); @@ -389,62 +173,28 @@ fn fmt_num(n: u64) -> String { out.chars().rev().collect() } -/// Comma-separated token counts (used by `list` and `usage-stats`). -pub fn format_token_count(n: u64) -> String { - fmt_num(n) -} - -fn sum_csv_rows(rows: &[CsvUsageRow]) -> RowAgg { - let mut a = RowAgg::default(); - for r in rows { - a.input_no_cache += r.input_no_cache; - a.input_cache_write += r.input_cache_write; - a.cache_read += r.cache_read; - a.output += r.output_tokens; - a.total_tokens += r.total_tokens; - a.cost_usd += r.cost_usd; - } - a -} - -#[derive(Debug, Clone)] -pub struct CursorMtdTotals { - pub input_tokens: u64, - pub output_tokens: u64, - pub cost_usd: f64, -} - -/// Month-to-date totals from Cursor's dashboard CSV (same source as [cstats](https://github.com/robinebers/cstats)). -/// Returns `None` if no local Cursor DB, export fails, or CSV cannot be parsed — fall back to probe metrics. -pub fn fetch_cursor_month_to_date_totals() -> Option { - if find_cursor_state_db().is_none() { - return None; +fn fmt_display_date(yyyymmdd: &str) -> String { + if yyyymmdd.len() == 8 { + format!( + "{}-{}-{}", + &yyyymmdd[0..4], + &yyyymmdd[4..6], + &yyyymmdd[6..8] + ) + } else { + yyyymmdd.to_string() } - let (since, until) = month_to_date_range_local().ok()?; - let (start_ms, end_ms) = to_epoch_range_ms(&since, &until).ok()?; - let csv_text = download_cursor_usage_csv(start_ms, end_ms).ok()?; - let rows = parse_usage_csv(&csv_text, &since, &until).ok()?; - let agg = sum_csv_rows(&rows); - Some(CursorMtdTotals { - input_tokens: agg.input_no_cache, - output_tokens: agg.output, - cost_usd: agg.cost_usd, - }) -} - -fn month_to_date_range_local() -> Result<(String, String)> { - let now = Local::now().date_naive(); - let first = NaiveDate::from_ymd_opt(now.year(), now.month(), 1) - .context("invalid month start")?; - let since = first.format("%Y%m%d").to_string(); - let until = now.format("%Y%m%d").to_string(); - Ok((since, until)) } fn print_summary_model_table(since: &str, until: &str, map: &HashMap) -> Result<()> { let mut total = RowAgg::default(); let mut items: Vec<(String, RowAgg)> = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - items.sort_by(|a, b| b.1.cost_usd.partial_cmp(&a.1.cost_usd).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.0.cmp(&b.0))); + items.sort_by(|a, b| { + b.1.cost_usd + .partial_cmp(&a.1.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); let mut rows: Vec = Vec::new(); for (model, a) in &items { @@ -485,9 +235,9 @@ fn print_summary_model_table(since: &str, until: &str, map: &HashMap Result<()> { - let w = cli_width::terminal_width(); + let w = crate::cli_width::terminal_width(); let tw = (w as usize).saturating_sub(4).max(20); - if w < cli_width::WIDTH_FULL_TABLE_AT { + if w < crate::cli_width::WIDTH_FULL_TABLE_AT { for r in rows { if r.model == "Total" { println!("---"); @@ -495,7 +245,7 @@ fn render_summary_model_output(rows: &[SummaryModelRow]) -> Result<()> { "Total In: {} Out: {} CacheW: {} CacheR: {} Total tok: {} {}", r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd ); - for line in cli_width::wrap_plain(&line, tw).lines() { + for line in crate::cli_width::wrap_plain(&line, tw).lines() { println!("{line}"); } continue; @@ -506,7 +256,7 @@ fn render_summary_model_output(rows: &[SummaryModelRow]) -> Result<()> { "In: {} Out: {} CacheW: {} CacheR: {} Total: {} {}", r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd ); - for pl in cli_width::wrap_plain(&line, tw).lines() { + for pl in crate::cli_width::wrap_plain(&line, tw).lines() { println!(" {pl}"); } } @@ -522,7 +272,12 @@ fn render_summary_model_output(rows: &[SummaryModelRow]) -> Result<()> { fn print_summary_provider_table(since: &str, until: &str, map: &HashMap) -> Result<()> { let mut total = RowAgg::default(); let mut items: Vec<(String, RowAgg)> = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - items.sort_by(|a, b| b.1.cost_usd.partial_cmp(&a.1.cost_usd).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.0.cmp(&b.0))); + items.sort_by(|a, b| { + b.1.cost_usd + .partial_cmp(&a.1.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); let mut rows: Vec = Vec::new(); for (prov, a) in &items { @@ -563,9 +318,9 @@ fn print_summary_provider_table(since: &str, until: &str, map: &HashMap Result<()> { - let w = cli_width::terminal_width(); + let w = crate::cli_width::terminal_width(); let tw = (w as usize).saturating_sub(4).max(20); - if w < cli_width::WIDTH_FULL_TABLE_AT { + if w < crate::cli_width::WIDTH_FULL_TABLE_AT { for r in rows { if r.provider == "Total" { println!("---"); @@ -573,7 +328,7 @@ fn render_summary_provider_output(rows: &[SummaryProviderRow]) -> Result<()> { "Total In: {} Out: {} CacheW: {} CacheR: {} Total tok: {} {}", r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd ); - for line in cli_width::wrap_plain(&line, tw).lines() { + for line in crate::cli_width::wrap_plain(&line, tw).lines() { println!("{line}"); } continue; @@ -584,7 +339,7 @@ fn render_summary_provider_output(rows: &[SummaryProviderRow]) -> Result<()> { "In: {} Out: {} CacheW: {} CacheR: {} Total: {} {}", r.input, r.output, r.cache_write, r.cache_hit, r.total_tokens, r.cost_usd ); - for pl in cli_width::wrap_plain(&line, tw).lines() { + for pl in crate::cli_width::wrap_plain(&line, tw).lines() { println!(" {pl}"); } } @@ -596,279 +351,3 @@ fn render_summary_provider_output(rows: &[SummaryProviderRow]) -> Result<()> { } Ok(()) } - -fn fmt_display_date(yyyymmdd: &str) -> String { - if yyyymmdd.len() == 8 { - format!( - "{}-{}-{}", - &yyyymmdd[0..4], - &yyyymmdd[4..6], - &yyyymmdd[6..8] - ) - } else { - yyyymmdd.to_string() - } -} - -fn resolve_date_range(since: Option<&str>, until: Option<&str>) -> Result<(String, String)> { - let default_until = Local::now().date_naive(); - let default_since = default_until - chrono::Duration::days(30); - let def_since = default_since.format("%Y%m%d").to_string(); - let def_until = default_until.format("%Y%m%d").to_string(); - - let since = since.map(|s| s.to_string()).unwrap_or(def_since); - let until = until.map(|s| s.to_string()).unwrap_or(def_until); - validate_yyyymmdd(&since)?; - validate_yyyymmdd(&until)?; - if since > until { - bail!("--since must be on or before --until"); - } - Ok((since, until)) -} - -fn validate_yyyymmdd(s: &str) -> Result<()> { - if s.len() != 8 || !s.chars().all(|c| c.is_ascii_digit()) { - bail!("Invalid date {s:?}: expected YYYYMMDD"); - } - let y: i32 = s[0..4].parse()?; - let m: u32 = s[4..6].parse()?; - let d: u32 = s[6..8].parse()?; - NaiveDate::from_ymd_opt(y, m, d).with_context(|| format!("invalid calendar date {s}"))?; - Ok(()) -} - -fn to_epoch_range_ms(since: &str, until: &str) -> Result<(i64, i64)> { - let y1: i32 = since[0..4].parse()?; - let m1: u32 = since[4..6].parse()?; - let d1: u32 = since[6..8].parse()?; - let y2: i32 = until[0..4].parse()?; - let m2: u32 = until[4..6].parse()?; - let d2: u32 = until[6..8].parse()?; - let nd1 = NaiveDate::from_ymd_opt(y1, m1, d1).unwrap(); - let nd2 = NaiveDate::from_ymd_opt(y2, m2, d2).unwrap(); - let start = Local - .from_local_datetime(&nd1.and_hms_opt(0, 0, 0).unwrap()) - .unwrap() - .timestamp_millis(); - let end = Local - .from_local_datetime(&nd2.and_hms_opt(23, 59, 59).unwrap()) - .unwrap() - .timestamp_millis(); - Ok((start, end)) -} - -fn expand_home(p: &str) -> PathBuf { - if let Some(rest) = p.strip_prefix("~/") { - if let Some(h) = dirs::home_dir() { - return h.join(rest); - } - } - PathBuf::from(p) -} - -fn find_cursor_state_db() -> Option { - let mac = expand_home("~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"); - let linux = expand_home("~/.config/Cursor/User/globalStorage/state.vscdb"); - let win = expand_home("~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb"); - for p in [mac, linux, win] { - if p.exists() { - return Some(p); - } - } - None -} - -fn read_sqlite_value(db_path: &PathBuf, key: &str) -> Result> { - let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; - let mut stmt = conn.prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1")?; - let mut rows = stmt.query_map([key], |row| row.get::<_, String>(0))?; - if let Some(r) = rows.next() { - let v = r?; - if v.trim().is_empty() { - return Ok(None); - } - return Ok(Some(v)); - } - Ok(None) -} - -fn write_sqlite_value(db_path: &PathBuf, key: &str, value: &str) -> Result<()> { - let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; - conn.execute( - "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?1, ?2)", - [key, value], - )?; - Ok(()) -} - -#[derive(Deserialize)] -struct JwtPayload { - sub: Option, - exp: Option, -} - -fn decode_jwt_payload(token: &str) -> Option { - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() < 2 { - return None; - } - let mut b64 = parts[1].replace('-', "+").replace('_', "/"); - let pad = (4 - b64.len() % 4) % 4; - b64.extend(std::iter::repeat('=').take(pad)); - let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).ok()?; - serde_json::from_slice(&bytes).ok() -} - -fn user_id_from_sub(sub: &str) -> String { - let parts: Vec<&str> = sub.split('|').collect(); - if parts.len() >= 2 { - parts[parts.len() - 1].trim().to_string() - } else { - parts[0].trim().to_string() - } -} - -fn build_session_cookie(access_token: &str) -> Result { - let payload = decode_jwt_payload(access_token).context("invalid JWT access token")?; - let sub = payload.sub.as_deref().context("JWT missing sub")?; - let user_id = user_id_from_sub(sub); - let session = format!("{}%3A%3A{}", user_id, access_token); - Ok(format!("WorkosCursorSessionToken={session}")) -} - -fn needs_refresh(access_token: Option<&str>) -> bool { - let Some(t) = access_token else { - return true; - }; - let Some(p) = decode_jwt_payload(t) else { - return true; - }; - let Some(exp) = p.exp else { - return true; - }; - let now_ms = Utc::now().timestamp_millis(); - exp * 1000 <= now_ms + REFRESH_BUFFER_MS -} - -#[derive(Deserialize)] -struct RefreshBody { - access_token: Option, - should_logout: Option, -} - -fn refresh_access_token(refresh_token: &str, db_path: &PathBuf) -> Result> { - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(30)) - .build()?; - let resp = client - .post(REFRESH_URL) - .header("Content-Type", "application/json") - .json(&json!({ - "grant_type": "refresh_token", - "client_id": CLIENT_ID, - "refresh_token": refresh_token, - })) - .send()?; - - let status = resp.status(); - let body_text = resp.text()?; - if status == 400 || status == 401 { - let j: serde_json::Value = serde_json::from_str(&body_text).unwrap_or(json!({})); - if j.get("shouldLogout").and_then(|v| v.as_bool()) == Some(true) { - bail!("Cursor session expired. Open Cursor and sign in again."); - } - bail!("Token refresh failed ({status}). Open Cursor and sign in again."); - } - if !status.is_success() { - return Ok(None); - } - let body: RefreshBody = serde_json::from_str(&body_text).unwrap_or(RefreshBody { - access_token: None, - should_logout: None, - }); - if body.should_logout == Some(true) { - bail!("Cursor session expired. Open Cursor and sign in again."); - } - let Some(at) = body.access_token.filter(|s| !s.is_empty()) else { - return Ok(None); - }; - let _ = write_sqlite_value(db_path, ACCESS_KEY, &at); - Ok(Some(at)) -} - -fn resolve_cursor_access_token(db_path: &PathBuf) -> Result { - let mut access = read_sqlite_value(db_path, ACCESS_KEY)?; - let refresh = read_sqlite_value(db_path, REFRESH_KEY)?; - - if access.is_none() && refresh.is_none() { - bail!( - "No Cursor auth in {}. Sign in via the Cursor app (tokens stored in state.vscdb).", - db_path.display() - ); - } - - if needs_refresh(access.as_deref()) { - if let Some(ref rt) = refresh { - if let Some(new_a) = refresh_access_token(rt, db_path)? { - access = Some(new_a); - } - } - } - - access.context("No usable Cursor access token. Open Cursor and sign in again.") -} - -fn download_cursor_usage_csv(start_ms: i64, end_ms: i64) -> Result { - let db_path = find_cursor_state_db().context( - "Cursor state.vscdb not found. Install Cursor and sign in, or set up Linux paths under ~/.config/Cursor/…", - )?; - - let access = resolve_cursor_access_token(&db_path)?; - let cookie = build_session_cookie(&access)?; - - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(120)) - .build()?; - - let url = format!( - "{EXPORT_URL}?startDate={}&endDate={}&strategy=tokens", - start_ms, end_ms - ); - let resp = client - .get(&url) - .header("Cookie", cookie) - .header("Accept", "text/csv") - .header( - "User-Agent", - "Mozilla/5.0 (compatible; crossusage-cli usage-stats)", - ) - .send()?; - - if resp.status() == 401 || resp.status() == 403 { - bail!("Cursor export returned {} — auth may have expired. Open Cursor and retry.", resp.status()); - } - if !resp.status().is_success() { - bail!("Cursor export failed: HTTP {}", resp.status()); - } - Ok(resp.text()?) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_tiny_csv() { - let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost -2026-03-01T12:00:00Z,Usage,gpt-5,No,100,200,300,400,1000,$1.50 -"#; - let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].model, "gpt-5"); - assert_eq!(rows[0].input_no_cache, 200); - assert_eq!(rows[0].input_cache_write, 100); - assert_eq!(rows[0].cache_read, 300); - assert_eq!(rows[0].output_tokens, 400); - assert!((rows[0].cost_usd - 1.5).abs() < 0.001); - } -} diff --git a/crates/crossusage-cli/src/main.rs b/crates/crossusage-cli/src/main.rs index 6a3d92b1..27bdbeaf 100644 --- a/crates/crossusage-cli/src/main.rs +++ b/crates/crossusage-cli/src/main.rs @@ -529,8 +529,10 @@ fn run_list_cmd( .map(|c| format!("{:.2}", c)) .unwrap_or_else(|| "—".into()); - if p.manifest.id == "cursor" { - if let Some(mtd) = cursor_token_usage::fetch_cursor_month_to_date_totals() { + if p.manifest.id == "cursor" || p.manifest.id == "cursor-nightly" { + if let Some(mtd) = + cursor_token_usage::fetch_cursor_month_to_date_totals_for_plugin(&p.manifest.id) + { input_s = cursor_token_usage::format_token_count(mtd.input_tokens); output_s = cursor_token_usage::format_token_count(mtd.output_tokens); cost_s = format!("{:.2}", mtd.cost_usd); diff --git a/crates/crossusage-core/Cargo.toml b/crates/crossusage-core/Cargo.toml index 5b4f8b3c..8cc3f780 100644 --- a/crates/crossusage-core/Cargo.toml +++ b/crates/crossusage-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage-core" -version = "1.0.11" +version = "1.1.0" description = "Shared plugin engine for CrossUsage (GUI + CLI)" edition = "2024" @@ -13,11 +13,14 @@ aes-gcm = "0.10.3" base64 = "0.22" log = "0.4" regex-lite = "0.1.9" -reqwest = { version = "0.13.3", features = ["blocking", "socks"] } +reqwest = { version = "0.13.3", features = ["blocking", "json", "socks"] } rquickjs = { version = "0.12", features = ["bindgen"] } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +anyhow = "1" +chrono = "0.4" +csv = "1.3" sha2 = "0.11" time = { version = "0.3.47", features = ["formatting", "parsing"] } dirs = "6" @@ -29,3 +32,4 @@ users = "0.11" [dev-dependencies] tempfile = "3" +serial_test = "3.5" diff --git a/crates/crossusage-core/src/cursor_paths.rs b/crates/crossusage-core/src/cursor_paths.rs new file mode 100644 index 00000000..898af782 --- /dev/null +++ b/crates/crossusage-core/src/cursor_paths.rs @@ -0,0 +1,146 @@ +//! Cursor Stable vs Cursor Nightly install paths (separate `state.vscdb` per app). + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OpenFlags}; + +const STATE_DB_SUFFIX: &str = "User/globalStorage/state.vscdb"; +const ACCESS_KEY: &str = "cursorAuth/accessToken"; +const REFRESH_KEY: &str = "cursorAuth/refreshToken"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CursorInstall { + Stable, + Nightly, +} + +impl CursorInstall { + pub fn app_dir_name(self) -> &'static str { + match self { + Self::Stable => "Cursor", + Self::Nightly => "Cursor Nightly", + } + } + + pub fn from_plugin_id(plugin_id: &str) -> Option { + match plugin_id.trim() { + "cursor" => Some(Self::Stable), + "cursor-nightly" => Some(Self::Nightly), + _ => None, + } + } +} + +fn expand_home(path: &str) -> PathBuf { + let trimmed = path.trim(); + if trimmed == "~" { + return dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + } + if let Some(rest) = trimmed.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } + PathBuf::from(trimmed) +} + +fn platform_roots() -> Vec { + vec![ + expand_home("~/.config"), + expand_home("~/Library/Application Support"), + expand_home("~/AppData/Roaming"), + ] +} + +/// `state.vscdb` for one install only (stable **or** nightly — never merged). +pub fn resolve_cursor_state_db_for(install: CursorInstall) -> Option { + if let Ok(custom) = std::env::var("CURSOR_STATE_DB") { + let custom = custom.trim(); + if !custom.is_empty() { + let p = expand_home(custom); + if p.is_file() { + return Some(p); + } + } + } + for root in platform_roots() { + let p = root.join(install.app_dir_name()).join(STATE_DB_SUFFIX); + if p.is_file() { + return Some(p); + } + } + None +} + +pub fn resolve_cursor_state_db_for_plugin_id(plugin_id: &str) -> Option { + CursorInstall::from_plugin_id(plugin_id).and_then(resolve_cursor_state_db_for) +} + +/// Default stable DB (CLI / legacy callers). +pub fn resolve_cursor_state_db() -> Option { + resolve_cursor_state_db_for(CursorInstall::Stable).or_else(|| { + resolve_cursor_state_db_for(CursorInstall::Nightly) + }) +} + +fn read_sqlite_value(db_path: &Path, key: &str) -> Option { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?; + let mut stmt = conn + .prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1") + .ok()?; + let mut rows = stmt.query_map([key], |row| row.get::<_, String>(0)).ok()?; + let row = rows.next()?.ok()?; + let trimmed = row.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn cursor_state_db_has_auth(db_path: &Path) -> bool { + read_sqlite_value(db_path, ACCESS_KEY).is_some() + || read_sqlite_value(db_path, REFRESH_KEY).is_some() +} + +fn path_has_app_dir(db_path: &Path, app_name: &str) -> bool { + db_path + .components() + .any(|c| c.as_os_str() == OsStr::new(app_name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_and_nightly_resolve_to_distinct_linux_paths() { + let stable = resolve_cursor_state_db_for(CursorInstall::Stable).map(|p| p.to_string_lossy().to_string()); + let nightly = resolve_cursor_state_db_for(CursorInstall::Nightly).map(|p| p.to_string_lossy().to_string()); + let stable_path = expand_home("~/.config/Cursor/User/globalStorage/state.vscdb"); + let nightly_path = expand_home("~/.config/Cursor Nightly/User/globalStorage/state.vscdb"); + if stable_path.is_file() { + assert_eq!(stable.as_deref(), Some(stable_path.to_string_lossy().as_ref())); + } + if nightly_path.is_file() { + assert_eq!(nightly.as_deref(), Some(nightly_path.to_string_lossy().as_ref())); + } + if stable.is_some() && nightly.is_some() { + assert_ne!(stable, nightly); + } + } + + #[test] + fn from_plugin_id_maps_both_providers() { + assert_eq!( + CursorInstall::from_plugin_id("cursor"), + Some(CursorInstall::Stable) + ); + assert_eq!( + CursorInstall::from_plugin_id("cursor-nightly"), + Some(CursorInstall::Nightly) + ); + assert_eq!(CursorInstall::from_plugin_id("claude"), None); + } +} diff --git a/crates/crossusage-core/src/cursor_usage_export.rs b/crates/crossusage-core/src/cursor_usage_export.rs new file mode 100644 index 00000000..a7bd51b1 --- /dev/null +++ b/crates/crossusage-core/src/cursor_usage_export.rs @@ -0,0 +1,834 @@ +//! Cursor token-level usage from the dashboard CSV export (same source as cstats). + +use anyhow::{bail, Context, Result}; +use base64::Engine; +use chrono::{DateTime, Datelike, Local, NaiveDate, TimeZone, Utc}; +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const REFRESH_URL: &str = "https://api2.cursor.sh/oauth/token"; +const CLIENT_ID: &str = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"; +const EXPORT_URL: &str = "https://cursor.com/api/dashboard/export-usage-events-csv"; +const ACCESS_KEY: &str = "cursorAuth/accessToken"; +const REFRESH_KEY: &str = "cursorAuth/refreshToken"; +const REFRESH_BUFFER_MS: i64 = 5 * 60 * 1000; + +#[derive(Debug, Clone, Default)] +pub struct RowAgg { + pub input_no_cache: u64, + pub input_cache_write: u64, + pub cache_read: u64, + pub output: u64, + pub total_tokens: u64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone)] +pub struct CsvUsageRow { + /// Local calendar day `YYYY-MM-DD`. + day_key: String, + model: String, + input_cache_write: u64, + input_no_cache: u64, + cache_read: u64, + output_tokens: u64, + total_tokens: u64, + cost_usd: f64, +} + +fn parse_int_cell(s: &str) -> u64 { + let t = s.trim(); + if t.is_empty() { + return 0; + } + let digits: String = t.chars().filter(|c| c.is_ascii_digit()).collect(); + digits.parse().unwrap_or(0) +} + +fn parse_cost_cell(s: &str) -> f64 { + let t = s.trim().trim_start_matches('$').replace(',', ""); + t.parse().unwrap_or(0.0) +} + +fn csv_date_to_yyyymmdd(raw: &str) -> Result { + let raw = raw.trim(); + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + let local = dt.with_timezone(&Local); + return Ok(format!( + "{:04}{:02}{:02}", + local.year(), + local.month(), + local.day() + )); + } + if let Ok(nd) = NaiveDate::parse_from_str( + raw.split('T').next().unwrap_or(raw), + "%Y-%m-%d", + ) { + return Ok(format!( + "{:04}{:02}{:02}", + nd.year(), + nd.month(), + nd.day() + )); + } + if raw.len() >= 10 && raw.as_bytes()[4] == b'-' && raw.as_bytes()[7] == b'-' { + if let Ok(nd) = NaiveDate::parse_from_str(&raw[..10], "%Y-%m-%d") { + return Ok(format!( + "{:04}{:02}{:02}", + nd.year(), + nd.month(), + nd.day() + )); + } + } + bail!("Unrecognized CSV date: {raw:?}") +} + +fn row_in_range(date_yyyymmdd: &str, since: &str, until: &str) -> bool { + date_yyyymmdd >= since && date_yyyymmdd <= until +} + +pub fn parse_usage_csv(text: &str, since: &str, until: &str) -> Result> { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(text.as_bytes()); + + let headers = rdr.headers()?.clone(); + let required = [ + "Date", + "Model", + "Input (w/ Cache Write)", + "Input (w/o Cache Write)", + "Cache Read", + "Output Tokens", + "Total Tokens", + "Cost", + ]; + for h in required { + if !headers.iter().any(|x| x == h) { + bail!("Cursor CSV missing column {h:?}. Export format may have changed."); + } + } + + let col = |name: &str| -> Result { + headers + .iter() + .position(|h| h == name) + .with_context(|| format!("missing column {name}")) + }; + let i_date = col("Date")?; + let i_model = col("Model")?; + + let mut out = Vec::new(); + for rec in rdr.records() { + let rec = rec?; + let date_raw = rec.get(i_date).unwrap_or(""); + let date_yyyymmdd = csv_date_to_yyyymmdd(date_raw)?; + if !row_in_range(&date_yyyymmdd, since, until) { + continue; + } + let get = |name: &str| -> Result<&str> { + let i = headers + .iter() + .position(|h| h == name) + .with_context(|| format!("missing column {name}"))?; + Ok(rec.get(i).unwrap_or("")) + }; + let model = rec.get(i_model).unwrap_or("").trim().to_string(); + if model.is_empty() { + continue; + } + let input_cache_write = parse_int_cell(get("Input (w/ Cache Write)")?); + let input_no_cache = parse_int_cell(get("Input (w/o Cache Write)")?); + let cache_read = parse_int_cell(get("Cache Read")?); + let output_tokens = parse_int_cell(get("Output Tokens")?); + let total_tokens = parse_int_cell(get("Total Tokens")?); + let cost_usd = parse_cost_cell(get("Cost")?); + + let sum_tokens = input_cache_write + input_no_cache + cache_read + output_tokens; + if input_no_cache == 0 + && output_tokens == 0 + && input_cache_write == 0 + && cache_read == 0 + && total_tokens == 0 + && cost_usd == 0.0 + { + continue; + } + + let day_key = format!( + "{}-{}-{}", + &date_yyyymmdd[0..4], + &date_yyyymmdd[4..6], + &date_yyyymmdd[6..8] + ); + out.push(CsvUsageRow { + day_key, + model, + input_cache_write, + input_no_cache, + cache_read, + output_tokens, + total_tokens: if total_tokens > 0 { + total_tokens + } else { + sum_tokens + }, + cost_usd, + }); + } + Ok(out) +} + +pub fn aggregate_by_day(rows: &[CsvUsageRow]) -> HashMap { + let mut m: HashMap = HashMap::new(); + for row in rows { + let e = m.entry(row.day_key.clone()).or_default(); + e.input_no_cache += row.input_no_cache; + e.input_cache_write += row.input_cache_write; + e.cache_read += row.cache_read; + e.output += row.output_tokens; + e.total_tokens += row.total_tokens; + e.cost_usd += row.cost_usd; + } + m +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyBillingRow { + pub date: String, + pub total_tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: f64, +} + +/// Per-day totals from the Cursor dashboard billing CSV (includes cost USD). +pub fn query_daily_billing( + plugin_id: &str, + since: Option<&str>, + until: Option<&str>, +) -> Result> { + let (since, until) = if since.is_none() && until.is_none() { + let (s, u) = month_to_date_range_local()?; + (s, u) + } else { + resolve_date_range(since, until)? + }; + let csv_text = fetch_csv_cached(plugin_id, &since, &until)?; + let rows = parse_usage_csv(&csv_text, &since, &until)?; + let by_day = aggregate_by_day(&rows); + let mut out: Vec = by_day + .into_iter() + .map(|(date, agg)| DailyBillingRow { + date, + total_tokens: agg.total_tokens, + input_tokens: agg.input_no_cache + agg.input_cache_write + agg.cache_read, + output_tokens: agg.output, + cost_usd: agg.cost_usd, + }) + .collect(); + out.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(out) +} + +pub fn query_daily_billing_host_json(opts_json: &str) -> String { + let since = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("since").and_then(|s| s.as_str()).map(str::to_string)); + let until = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("until").and_then(|s| s.as_str()).map(str::to_string)); + let plugin_id = plugin_id_from_opts_json(opts_json); + match query_daily_billing(&plugin_id, since.as_deref(), until.as_deref()) { + Ok(daily) => serde_json::json!({ "status": "ok", "data": { "daily": daily } }).to_string(), + Err(e) => serde_json::json!({ + "status": "error", + "message": e.to_string() + }) + .to_string(), + } +} + +pub fn aggregate_by_model(rows: &[CsvUsageRow]) -> HashMap { + let mut m: HashMap = HashMap::new(); + for row in rows { + let e = m.entry(row.model.clone()).or_default(); + e.input_no_cache += row.input_no_cache; + e.input_cache_write += row.input_cache_write; + e.cache_read += row.cache_read; + e.output += row.output_tokens; + e.total_tokens += row.total_tokens; + e.cost_usd += row.cost_usd; + } + m +} + +fn infer_provider(model: &str) -> String { + let s = model.to_lowercase(); + if s.contains("claude") { + return "anthropic".into(); + } + if s.contains("gemini") || s.contains("google") { + return "google".into(); + } + if s.contains("gpt") || s.contains("openai") { + return "openai".into(); + } + if s.contains("composer") || s.contains("cursor") || s.contains("kimi") { + return "cursor".into(); + } + if s.contains("deepseek") { + return "deepseek".into(); + } + "other".into() +} + +pub fn aggregate_by_provider(rows: &[CsvUsageRow]) -> HashMap { + let mut m: HashMap = HashMap::new(); + for row in rows { + let p = infer_provider(&row.model); + let e = m.entry(p).or_default(); + e.input_no_cache += row.input_no_cache; + e.input_cache_write += row.input_cache_write; + e.cache_read += row.cache_read; + e.output += row.output_tokens; + e.total_tokens += row.total_tokens; + e.cost_usd += row.cost_usd; + } + m +} + +fn fmt_num(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(c); + } + out.chars().rev().collect() +} + +/// Comma-separated token counts (used by `list` and `usage-stats`). +pub fn format_token_count(n: u64) -> String { + fmt_num(n) +} + +fn sum_csv_rows(rows: &[CsvUsageRow]) -> RowAgg { + let mut a = RowAgg::default(); + for r in rows { + a.input_no_cache += r.input_no_cache; + a.input_cache_write += r.input_cache_write; + a.cache_read += r.cache_read; + a.output += r.output_tokens; + a.total_tokens += r.total_tokens; + a.cost_usd += r.cost_usd; + } + a +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CursorMtdTotals { + pub total_tokens: u64, + pub input_tokens: u64, + pub output_tokens: u64, + pub cost_usd: f64, + pub since: String, + pub until: String, +} + +static MTD_CACHE: Mutex> = Mutex::new(None); +static CSV_RANGE_CACHE: Mutex> = Mutex::new(None); +const MTD_CACHE_TTL: Duration = Duration::from_secs(45 * 60); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageStatsRowJson { + pub key: String, + pub input: u64, + pub output: u64, + pub cache_write: u64, + pub cache_hit: u64, + pub total_tokens: u64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageStatsPayload { + pub since: String, + pub until: String, + pub group: String, + pub rows: Vec, + pub totals: UsageStatsRowJson, +} + +fn fetch_cursor_month_to_date_totals_uncached(plugin_id: &str) -> Option { + if resolve_state_db_for_plugin(plugin_id).is_none() { + return None; + } + let (since, until) = month_to_date_range_local().ok()?; + let (start_ms, end_ms) = to_epoch_range_ms(&since, &until).ok()?; + let csv_text = download_cursor_usage_csv_for_plugin(plugin_id, start_ms, end_ms).ok()?; + let rows = parse_usage_csv(&csv_text, &since, &until).ok()?; + let agg = sum_csv_rows(&rows); + Some(CursorMtdTotals { + total_tokens: agg.total_tokens, + input_tokens: agg.input_no_cache, + output_tokens: agg.output, + cost_usd: agg.cost_usd, + since, + until, + }) +} + +/// Month-to-date totals from Cursor's dashboard CSV (cached ~45m per month). +pub fn fetch_cursor_month_to_date_totals_for_plugin(plugin_id: &str) -> Option { + let (since, until) = month_to_date_range_local().ok()?; + let cache_key = format!("{plugin_id}:{since}-{until}"); + if let Ok(guard) = MTD_CACHE.lock() { + if let Some((key, at, totals)) = guard.as_ref() { + if key == &cache_key && at.elapsed() < MTD_CACHE_TTL { + return Some(totals.clone()); + } + } + } + let totals = fetch_cursor_month_to_date_totals_uncached(plugin_id)?; + if let Ok(mut guard) = MTD_CACHE.lock() { + *guard = Some((cache_key, Instant::now(), totals.clone())); + } + Some(totals) +} + +/// JSON for plugin host API (`cursorUsageExport.queryMtd`). +pub fn query_mtd_host_json(opts_json: &str) -> String { + let plugin_id = plugin_id_from_opts_json(opts_json); + match fetch_cursor_month_to_date_totals_for_plugin(&plugin_id) { + Some(data) => serde_json::json!({ "status": "ok", "data": data }).to_string(), + None => serde_json::json!({ + "status": "error", + "message": "Cursor MTD export unavailable (sign in via Cursor or try again later)" + }) + .to_string(), + } +} + +pub fn month_to_date_range_local() -> Result<(String, String)> { + let now = Local::now().date_naive(); + let first = NaiveDate::from_ymd_opt(now.year(), now.month(), 1).context("invalid month start")?; + let since = first.format("%Y%m%d").to_string(); + let until = now.format("%Y%m%d").to_string(); + Ok((since, until)) +} + +pub fn resolve_date_range(since: Option<&str>, until: Option<&str>) -> Result<(String, String)> { + let default_until = Local::now().date_naive(); + let default_since = default_until - chrono::Duration::days(30); + let def_since = default_since.format("%Y%m%d").to_string(); + let def_until = default_until.format("%Y%m%d").to_string(); + + let since = since.map(|s| s.to_string()).unwrap_or(def_since); + let until = until.map(|s| s.to_string()).unwrap_or(def_until); + validate_yyyymmdd(&since)?; + validate_yyyymmdd(&until)?; + if since > until { + bail!("--since must be on or before --until"); + } + Ok((since, until)) +} + +fn validate_yyyymmdd(s: &str) -> Result<()> { + if s.len() != 8 || !s.chars().all(|c| c.is_ascii_digit()) { + bail!("Invalid date {s:?}: expected YYYYMMDD"); + } + let y: i32 = s[0..4].parse()?; + let m: u32 = s[4..6].parse()?; + let d: u32 = s[6..8].parse()?; + NaiveDate::from_ymd_opt(y, m, d).with_context(|| format!("invalid calendar date {s}"))?; + Ok(()) +} + +pub fn to_epoch_range_ms(since: &str, until: &str) -> Result<(i64, i64)> { + let y1: i32 = since[0..4].parse()?; + let m1: u32 = since[4..6].parse()?; + let d1: u32 = since[6..8].parse()?; + let y2: i32 = until[0..4].parse()?; + let m2: u32 = until[4..6].parse()?; + let d2: u32 = until[6..8].parse()?; + let nd1 = NaiveDate::from_ymd_opt(y1, m1, d1).unwrap(); + let nd2 = NaiveDate::from_ymd_opt(y2, m2, d2).unwrap(); + let start = Local + .from_local_datetime(&nd1.and_hms_opt(0, 0, 0).unwrap()) + .unwrap() + .timestamp_millis(); + let end = Local + .from_local_datetime(&nd2.and_hms_opt(23, 59, 59).unwrap()) + .unwrap() + .timestamp_millis(); + Ok((start, end)) +} + +fn plugin_id_from_opts_json(opts_json: &str) -> String { + serde_json::from_str::(opts_json) + .ok() + .and_then(|v| { + v.get("pluginId") + .or_else(|| v.get("baseProviderId")) + .and_then(|s| s.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "cursor".into()) +} + +fn resolve_state_db_for_plugin(plugin_id: &str) -> Option { + crate::cursor_paths::resolve_cursor_state_db_for_plugin_id(plugin_id) +} + +fn read_sqlite_value(db_path: &PathBuf, key: &str) -> Result> { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + let mut stmt = conn.prepare("SELECT value FROM ItemTable WHERE key = ?1 LIMIT 1")?; + let mut rows = stmt.query_map([key], |row| row.get::<_, String>(0))?; + if let Some(r) = rows.next() { + let v = r?; + if v.trim().is_empty() { + return Ok(None); + } + return Ok(Some(v)); + } + Ok(None) +} + +fn write_sqlite_value(db_path: &PathBuf, key: &str, value: &str) -> Result<()> { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_WRITE)?; + conn.execute( + "INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?1, ?2)", + [key, value], + )?; + Ok(()) +} + +#[derive(Deserialize)] +struct JwtPayload { + sub: Option, + exp: Option, +} + +fn decode_jwt_payload(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let mut b64 = parts[1].replace('-', "+").replace('_', "/"); + let pad = (4 - b64.len() % 4) % 4; + b64.extend(std::iter::repeat('=').take(pad)); + let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn user_id_from_sub(sub: &str) -> String { + let parts: Vec<&str> = sub.split('|').collect(); + if parts.len() >= 2 { + parts[parts.len() - 1].trim().to_string() + } else { + parts[0].trim().to_string() + } +} + +fn build_session_cookie(access_token: &str) -> Result { + let payload = decode_jwt_payload(access_token).context("invalid JWT access token")?; + let sub = payload.sub.as_deref().context("JWT missing sub")?; + let user_id = user_id_from_sub(sub); + let session = format!("{}%3A%3A{}", user_id, access_token); + Ok(format!("WorkosCursorSessionToken={session}")) +} + +fn needs_refresh(access_token: Option<&str>) -> bool { + let Some(t) = access_token else { + return true; + }; + let Some(p) = decode_jwt_payload(t) else { + return true; + }; + let Some(exp) = p.exp else { + return true; + }; + let now_ms = Utc::now().timestamp_millis(); + exp * 1000 <= now_ms + REFRESH_BUFFER_MS +} + +#[derive(Deserialize)] +struct RefreshBody { + access_token: Option, + should_logout: Option, +} + +fn refresh_access_token(refresh_token: &str, db_path: &PathBuf) -> Result> { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let resp = client + .post(REFRESH_URL) + .header("Content-Type", "application/json") + .json(&json!({ + "grant_type": "refresh_token", + "client_id": CLIENT_ID, + "refresh_token": refresh_token, + })) + .send()?; + + let status = resp.status(); + let body_text = resp.text()?; + if status == 400 || status == 401 { + let j: serde_json::Value = serde_json::from_str(&body_text).unwrap_or(json!({})); + if j.get("shouldLogout").and_then(|v| v.as_bool()) == Some(true) { + bail!("Cursor session expired. Open Cursor and sign in again."); + } + bail!("Token refresh failed ({status}). Open Cursor and sign in again."); + } + if !status.is_success() { + return Ok(None); + } + let body: RefreshBody = serde_json::from_str(&body_text).unwrap_or(RefreshBody { + access_token: None, + should_logout: None, + }); + if body.should_logout == Some(true) { + bail!("Cursor session expired. Open Cursor and sign in again."); + } + let Some(at) = body.access_token.filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let _ = write_sqlite_value(db_path, ACCESS_KEY, &at); + Ok(Some(at)) +} + +fn resolve_cursor_access_token(db_path: &PathBuf) -> Result { + let mut access = read_sqlite_value(db_path, ACCESS_KEY)?; + let refresh = read_sqlite_value(db_path, REFRESH_KEY)?; + + if access.is_none() && refresh.is_none() { + bail!( + "No Cursor auth in {}. Sign in via the Cursor app (tokens stored in state.vscdb).", + db_path.display() + ); + } + + if needs_refresh(access.as_deref()) { + if let Some(ref rt) = refresh { + if let Some(new_a) = refresh_access_token(rt, db_path)? { + access = Some(new_a); + } + } + } + + access.context("No usable Cursor access token. Open Cursor and sign in again.") +} + +pub fn download_cursor_usage_csv(start_ms: i64, end_ms: i64) -> Result { + download_cursor_usage_csv_for_plugin("cursor", start_ms, end_ms) +} + +pub fn download_cursor_usage_csv_for_plugin( + plugin_id: &str, + start_ms: i64, + end_ms: i64, +) -> Result { + let db_path = resolve_state_db_for_plugin(plugin_id).with_context(|| { + format!( + "Cursor state.vscdb not found for {plugin_id}. Install Cursor or Cursor Nightly and sign in." + ) + })?; + + let access = resolve_cursor_access_token(&db_path)?; + let cookie = build_session_cookie(&access)?; + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build()?; + + let url = format!( + "{EXPORT_URL}?startDate={}&endDate={}&strategy=tokens", + start_ms, end_ms + ); + let resp = client + .get(&url) + .header("Cookie", cookie) + .header("Accept", "text/csv") + .header( + "User-Agent", + "Mozilla/5.0 (compatible; crossusage-cli usage-stats)", + ) + .send()?; + + if resp.status() == 401 || resp.status() == 403 { + bail!("Cursor export returned {} — auth may have expired. Open Cursor and retry.", resp.status()); + } + if !resp.status().is_success() { + bail!("Cursor export failed: HTTP {}", resp.status()); + } + Ok(resp.text()?) +} + +fn fetch_csv_cached(plugin_id: &str, since: &str, until: &str) -> Result { + let cache_key = format!("{plugin_id}:{since}-{until}"); + if let Ok(guard) = CSV_RANGE_CACHE.lock() { + if let Some((key, at, text)) = guard.as_ref() { + if key == &cache_key && at.elapsed() < MTD_CACHE_TTL { + return Ok(text.clone()); + } + } + } + let (start_ms, end_ms) = to_epoch_range_ms(since, until)?; + let text = download_cursor_usage_csv_for_plugin(plugin_id, start_ms, end_ms)?; + if let Ok(mut guard) = CSV_RANGE_CACHE.lock() { + *guard = Some((cache_key, Instant::now(), text.clone())); + } + Ok(text) +} + +fn row_agg_to_json(key: String, a: &RowAgg) -> UsageStatsRowJson { + UsageStatsRowJson { + key, + input: a.input_no_cache, + output: a.output, + cache_write: a.input_cache_write, + cache_hit: a.cache_read, + total_tokens: a.total_tokens, + cost_usd: a.cost_usd, + } +} + +fn sorted_stats_rows(m: &HashMap) -> Vec { + let mut entries: Vec<_> = m + .iter() + .map(|(k, a)| row_agg_to_json(k.clone(), a)) + .collect(); + entries.sort_by(|a, b| { + b.cost_usd + .partial_cmp(&a.cost_usd) + .unwrap_or(std::cmp::Ordering::Equal) + }); + entries +} + +/// Usage stats for a date range (same data as CLI `usage-stats`). +pub fn query_usage_stats( + plugin_id: &str, + since: Option<&str>, + until: Option<&str>, + group: &str, +) -> Result { + let (since, until) = if since.is_none() && until.is_none() { + month_to_date_range_local()? + } else { + resolve_date_range(since, until)? + }; + let group = group.to_lowercase(); + if group != "model" && group != "provider" { + bail!("group must be 'model' or 'provider'"); + } + + let csv_text = fetch_csv_cached(plugin_id, &since, &until)?; + let rows = parse_usage_csv(&csv_text, &since, &until)?; + let map = if group == "model" { + aggregate_by_model(&rows) + } else { + aggregate_by_provider(&rows) + }; + let stats_rows = sorted_stats_rows(&map); + let totals_agg = sum_csv_rows(&rows); + let totals = row_agg_to_json("total".into(), &totals_agg); + + Ok(UsageStatsPayload { + since, + until, + group, + rows: stats_rows, + totals, + }) +} + +/// JSON for plugin host API (`cursorUsageExport.queryStats`). +pub fn query_usage_stats_host_json(opts_json: &str) -> String { + let plugin_id = plugin_id_from_opts_json(opts_json); + let since = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("since").and_then(|s| s.as_str()).map(str::to_string)); + let until = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("until").and_then(|s| s.as_str()).map(str::to_string)); + let group = serde_json::from_str::(opts_json) + .ok() + .and_then(|v| v.get("group").and_then(|s| s.as_str()).map(str::to_string)) + .unwrap_or_else(|| "model".into()); + + match query_usage_stats(&plugin_id, since.as_deref(), until.as_deref(), &group) { + Ok(data) => serde_json::json!({ "status": "ok", "data": data }).to_string(), + Err(e) => serde_json::json!({ + "status": "error", + "message": e.to_string() + }) + .to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_tiny_csv() { + let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost +2026-03-01T12:00:00Z,Usage,gpt-5,No,100,200,300,400,1000,$1.50 +"#; + let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].model, "gpt-5"); + assert_eq!(rows[0].input_no_cache, 200); + assert_eq!(rows[0].input_cache_write, 100); + assert_eq!(rows[0].cache_read, 300); + assert_eq!(rows[0].output_tokens, 400); + assert!((rows[0].cost_usd - 1.5).abs() < 0.001); + } + + #[test] + fn aggregate_by_day_sums_cost() { + let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost +2026-03-01,Usage,gpt-5,No,100,200,300,400,1000,$1.50 +2026-03-01,Usage,claude-4,No,0,50,0,50,100,$0.25 +2026-03-02,Usage,gpt-5,No,0,10,0,10,20,$0.10 +"#; + let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); + let by_day = aggregate_by_day(&rows); + assert_eq!(by_day.len(), 2); + let mar1 = by_day.get("2026-03-01").unwrap(); + assert!((mar1.cost_usd - 1.75).abs() < 0.001); + assert_eq!(mar1.total_tokens, 1100); + } + + #[test] + fn usage_stats_sorts_by_cost() { + let csv = r#"Date,Kind,Model,Max Mode,Input (w/ Cache Write),Input (w/o Cache Write),Cache Read,Output Tokens,Total Tokens,Cost +2026-03-01T12:00:00Z,Usage,cheap,No,0,100,0,100,200,$0.50 +2026-03-01T12:00:00Z,Usage,pricey,No,0,100,0,100,200,$2.00 +"#; + let rows = parse_usage_csv(csv, "20260301", "20260331").unwrap(); + let by_model = aggregate_by_model(&rows); + let stats = sorted_stats_rows(&by_model); + assert_eq!(stats[0].key, "pricey"); + assert!(stats[0].cost_usd > stats[1].cost_usd); + } +} diff --git a/crates/crossusage-core/src/lib.rs b/crates/crossusage-core/src/lib.rs index 8c5a4938..0537f697 100644 --- a/crates/crossusage-core/src/lib.rs +++ b/crates/crossusage-core/src/lib.rs @@ -5,6 +5,8 @@ pub mod plugin_engine; pub mod provider_accounts; pub mod proxy_config; pub mod usage_metrics; +pub mod cursor_paths; +pub mod cursor_usage_export; pub mod cursor_usage_logs; pub mod usage_daily; pub mod usage_history; diff --git a/crates/crossusage-core/src/plugin_engine/host_api.rs b/crates/crossusage-core/src/plugin_engine/host_api.rs index b56013c3..4ba023ac 100644 --- a/crates/crossusage-core/src/plugin_engine/host_api.rs +++ b/crates/crossusage-core/src/plugin_engine/host_api.rs @@ -5,7 +5,7 @@ use aes_gcm::{ }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use crate::provider_accounts::{self, ProviderAccountContext, ProviderCredential}; -use rquickjs::{Ctx, Exception, Function, Object}; +use rquickjs::{function::Rest, Ctx, Exception, Function, Object}; use rusqlite::{Connection, OpenFlags}; use serde_json::{Map, Value as JsonValue}; use sha2::{Digest, Sha256}; @@ -695,6 +695,7 @@ pub(crate) fn inject_host_api_with_deadline<'js>( host.set("account", host_account_obj)?; inject_log(ctx, &host, instance_id)?; inject_fs(ctx, &host)?; + inject_cursor_paths(ctx, &host, base_plugin_id)?; inject_crypto(ctx, &host)?; inject_env(ctx, &host, base_plugin_id)?; inject_http(ctx, &host, instance_id, deadline)?; @@ -705,6 +706,7 @@ pub(crate) fn inject_host_api_with_deadline<'js>( inject_ccusage(ctx, &host, base_plugin_id, deadline)?; inject_usage_daily(ctx, &host, instance_id, app_data_dir)?; inject_cursor_logs(ctx, &host, base_plugin_id, deadline)?; + inject_cursor_usage_export(ctx, &host, deadline)?; inject_fireworks(ctx, &host, base_plugin_id)?; probe_ctx.set("host", host)?; @@ -1032,6 +1034,24 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { Ok(()) } +fn inject_cursor_paths<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + base_plugin_id: &str, +) -> rquickjs::Result<()> { + let paths_obj = Object::new(ctx.clone())?; + let plugin_id = base_plugin_id.to_string(); + paths_obj.set( + "resolveStateDb", + Function::new(ctx.clone(), move |_ctx: Ctx<'_>, ()| -> Option { + crate::cursor_paths::resolve_cursor_state_db_for_plugin_id(&plugin_id) + .map(|p| p.to_string_lossy().to_string()) + })?, + )?; + host.set("cursorPaths", paths_obj)?; + Ok(()) +} + fn inject_crypto<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let crypto_obj = Object::new(ctx.clone())?; @@ -2975,6 +2995,105 @@ fn inject_cursor_logs<'js>( Ok(()) } +fn inject_cursor_usage_export<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + deadline: ProbeDeadline, +) -> rquickjs::Result<()> { + let export_obj = Object::new(ctx.clone())?; + export_obj.set( + "_queryMtdRaw", + Function::new(ctx.clone(), move |_ctx_inner: Ctx<'_>, opts: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok(serde_json::json!({ + "status": "error", + "message": "probe deadline exceeded" + }) + .to_string()); + } + Ok(crate::cursor_usage_export::query_mtd_host_json(&opts)) + })?, + )?; + export_obj.set( + "_queryStatsRaw", + Function::new(ctx.clone(), move |_ctx_inner: Ctx<'_>, opts: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok(serde_json::json!({ + "status": "error", + "message": "probe deadline exceeded" + }) + .to_string()); + } + Ok(crate::cursor_usage_export::query_usage_stats_host_json(&opts)) + })?, + )?; + export_obj.set( + "_queryDailyRaw", + Function::new(ctx.clone(), move |_ctx_inner: Ctx<'_>, opts: String| -> rquickjs::Result { + if deadline.has_elapsed() { + return Ok(serde_json::json!({ + "status": "error", + "message": "probe deadline exceeded" + }) + .to_string()); + } + Ok(crate::cursor_usage_export::query_daily_billing_host_json(&opts)) + })?, + )?; + host.set("cursorUsageExport", export_obj)?; + Ok(()) +} + +pub fn patch_cursor_usage_export_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { + ctx.eval::<(), _>( + r#" + (function() { + function cursorExportOpts(opts) { + var o = opts && typeof opts === "object" ? Object.assign({}, opts) : {}; + if (!o.pluginId && __openusage_ctx.account && __openusage_ctx.account.baseProviderId) { + o.pluginId = __openusage_ctx.account.baseProviderId; + } + return JSON.stringify(o); + } + var rawFn = __openusage_ctx.host.cursorUsageExport._queryMtdRaw; + __openusage_ctx.host.cursorUsageExport.queryMtd = function(opts) { + var result = rawFn(cursorExportOpts(opts)); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "error", message: "invalid MTD response" }; + }; + var statsRawFn = __openusage_ctx.host.cursorUsageExport._queryStatsRaw; + __openusage_ctx.host.cursorUsageExport.queryStats = function(opts) { + var result = statsRawFn(cursorExportOpts(opts)); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "error", message: "invalid usage stats response" }; + }; + var dailyRawFn = __openusage_ctx.host.cursorUsageExport._queryDailyRaw; + __openusage_ctx.host.cursorUsageExport.queryDaily = function(opts) { + var result = dailyRawFn(cursorExportOpts(opts)); + try { + var parsed = JSON.parse(result); + if (parsed && typeof parsed === "object" && typeof parsed.status === "string") { + return parsed; + } + } catch (e) {} + return { status: "error", message: "invalid daily billing response" }; + }; + })(); + "# + .as_bytes(), + ) +} + pub fn patch_cursor_logs_wrapper(ctx: &rquickjs::Ctx<'_>) -> rquickjs::Result<()> { ctx.eval::<(), _>( r#" @@ -3338,16 +3457,21 @@ fn inject_keychain<'js>( ctx.clone(), move |ctx_inner: Ctx<'_>, service: String, - account: Option| + account_args: Rest>| -> rquickjs::Result { - let account = account.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }); + let account = account_args + .0 + .into_iter() + .next() + .flatten() + .and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); let redacted_account = account.as_deref().map(redact_value); if let Some(ref redacted) = redacted_account { log::info!( @@ -4024,6 +4148,36 @@ mod tests { }); } + #[test] + fn keychain_read_generic_password_accepts_optional_account_arg_from_js() { + let rt = Runtime::new().expect("runtime"); + let ctx = Context::full(&rt).expect("context"); + ctx.with(|ctx| { + let app_data = std::env::temp_dir(); + inject_host_api(&ctx, "test", "test", None, &app_data, "0.0.0") + .expect("inject host api"); + + let message: String = ctx + .eval( + r#" + try { + __openusage_ctx.host.keychain.readGenericPassword("__openusage_missing_service__"); + "ok"; + } catch (e) { + String(e); + } + "#, + ) + .expect("js eval"); + + assert!( + !message.contains("2 where expected"), + "single-arg call should reach the keychain implementation, got: {}", + message + ); + }); + } + #[test] fn crypto_api_exposes_sha256_hex() { let rt = Runtime::new().expect("runtime"); diff --git a/crates/crossusage-core/src/plugin_engine/manifest.rs b/crates/crossusage-core/src/plugin_engine/manifest.rs index df832b0f..2937a9b4 100644 --- a/crates/crossusage-core/src/plugin_engine/manifest.rs +++ b/crates/crossusage-core/src/plugin_engine/manifest.rs @@ -114,7 +114,7 @@ fn load_single_plugin( return Err("plugin icon must remain within plugin directory".into()); } let icon_bytes = std::fs::read(&icon_file_path)?; - let icon_data_url = format!("data:image/svg+xml;base64,{}", STANDARD.encode(&icon_bytes)); + let icon_data_url = icon_data_url_for_file(&icon_file_path, &icon_bytes); Ok(LoadedPlugin { manifest, @@ -125,6 +125,16 @@ fn load_single_plugin( }) } +fn icon_data_url_for_file(path: &Path, bytes: &[u8]) -> String { + let mime = match path.extension().and_then(|ext| ext.to_str()) { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + _ => "image/svg+xml", + }; + format!("data:{mime};base64,{}", STANDARD.encode(bytes)) +} + fn sanitize_plugin_links(plugin_id: &str, links: Vec) -> Vec { links .into_iter() @@ -273,6 +283,18 @@ mod tests { assert_eq!(manifest.links[1].url, "https://example.com/billing"); } + #[test] + fn icon_data_url_uses_png_mime_for_png_files() { + let mime = icon_data_url_for_file(Path::new("icon.png"), b"\x89PNG"); + assert!(mime.starts_with("data:image/png;base64,")); + } + + #[test] + fn icon_data_url_uses_svg_mime_for_svg_files() { + let mime = icon_data_url_for_file(Path::new("icon.svg"), b", ) -> (PathBuf, Vec) { if let Some(dev_dir) = find_dev_plugins_dir() { if !is_dir_empty(&dev_dir) { - let plugins = manifest::load_plugins_from_dir(&dev_dir); + let plugins = load_active_plugins_from_dir(&dev_dir); return (dev_dir, plugins); } } @@ -28,14 +30,40 @@ pub fn initialize_plugins( if let Some(res) = resource_dir { let bundled_dir = resolve_bundled_dir(res); if bundled_dir.exists() { + let new_plugins = list_missing_plugin_dirs(&bundled_dir, &install_dir); copy_dir_recursive(&bundled_dir, &install_dir); + remove_retired_bundled_plugins(&install_dir); + if !new_plugins.is_empty() { + log::info!( + "synced {} new bundled plugin(s) into {}: {}", + new_plugins.len(), + install_dir.display(), + new_plugins.join(", ") + ); + } + } else { + log::warn!( + "bundled plugins dir missing at {}", + bundled_dir.display() + ); } } - let plugins = manifest::load_plugins_from_dir(&install_dir); + let plugins = load_active_plugins_from_dir(&install_dir); (install_dir, plugins) } +fn load_active_plugins_from_dir(plugins_dir: &Path) -> Vec { + manifest::load_plugins_from_dir(plugins_dir) + .into_iter() + .filter(|plugin| !is_retired_bundled_plugin_id(&plugin.manifest.id)) + .collect() +} + +fn is_retired_bundled_plugin_id(id: &str) -> bool { + RETIRED_BUNDLED_PLUGIN_IDS.contains(&id) +} + fn find_dev_plugins_dir() -> Option { let cwd = std::env::current_dir().ok()?; let direct = cwd.join("plugins"); @@ -68,53 +96,254 @@ fn is_dir_empty(path: &Path) -> bool { } } -fn copy_dir_recursive(src: &Path, dst: &Path) { - match std::fs::read_dir(src) { - Ok(entries) => { - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(err) => { - log::warn!("failed to read entry in {}: {}", src.display(), err); - continue; - } - }; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - let file_type = match entry.file_type() { - Ok(file_type) => file_type, - Err(err) => { - log::warn!( - "failed to read file type for {}: {}", - src_path.display(), - err - ); - continue; - } - }; - if file_type.is_symlink() { - continue; - } - if file_type.is_dir() { - if let Err(err) = std::fs::create_dir_all(&dst_path) { - log::warn!("failed to create dir {}: {}", dst_path.display(), err); - continue; - } - copy_dir_recursive(&src_path, &dst_path); - } else if file_type.is_file() { - if let Err(err) = std::fs::copy(&src_path, &dst_path) { - log::warn!( - "failed to copy {} to {}: {}", - src_path.display(), - dst_path.display(), - err - ); - } - } - } +fn remove_retired_bundled_plugins(install_dir: &Path) { + for id in RETIRED_BUNDLED_PLUGIN_IDS { + let plugin_dir = install_dir.join(id); + if !plugin_dir.is_dir() || !plugin_dir_has_id(&plugin_dir, id) { + continue; } + + if let Err(err) = std::fs::remove_dir_all(&plugin_dir) { + log::warn!( + "failed to remove retired bundled plugin {}: {}", + plugin_dir.display(), + err + ); + } + } +} + +fn plugin_dir_has_id(plugin_dir: &Path, expected_id: &str) -> bool { + let manifest_path = plugin_dir.join("plugin.json"); + let Ok(text) = std::fs::read_to_string(&manifest_path) else { + return false; + }; + let Ok(value) = serde_json::from_str::(&text) else { + return false; + }; + value + .get("id") + .and_then(|id| id.as_str()) + .is_some_and(|id| id == expected_id) +} + +fn list_missing_plugin_dirs(bundled_dir: &Path, install_dir: &Path) -> Vec { + let entries = match std::fs::read_dir(bundled_dir) { + Ok(entries) => entries, + Err(err) => { + log::warn!("failed to read bundled plugins dir {}: {}", bundled_dir.display(), err); + return Vec::new(); + } + }; + + let mut missing = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + if !path.join("plugin.json").is_file() { + continue; + } + let dst = install_dir.join(entry.file_name()); + if !dst.exists() { + missing.push(entry.file_name().to_string_lossy().into_owned()); + } + } + missing.sort(); + missing +} + +fn copy_dir_recursive(src: &Path, dst: &Path) { + let entries = match std::fs::read_dir(src) { + Ok(entries) => entries, Err(err) => { log::warn!("failed to read dir {}: {}", src.display(), err); + return; + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::warn!("failed to read entry in {}: {}", src.display(), err); + continue; + } + }; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + log::warn!( + "failed to read file type for {}: {}", + src_path.display(), + err + ); + continue; + } + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + if let Err(err) = std::fs::create_dir_all(&dst_path) { + log::warn!("failed to create dir {}: {}", dst_path.display(), err); + continue; + } + copy_dir_recursive(&src_path, &dst_path); + } else if file_type.is_file() { + if let Err(err) = std::fs::copy(&src_path, &dst_path) { + log::warn!( + "failed to copy {} to {}: {}", + src_path.display(), + dst_path.display(), + err + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TempDir { + path: PathBuf, + } + + impl TempDir { + fn new(name: &str) -> Self { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "crossusage-plugin-engine-{}-{}-{}", + name, + std::process::id(), + suffix + )); + fs::create_dir_all(&path).expect("create temp dir"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path } } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + struct CurrentDirGuard { + original: PathBuf, + } + + impl CurrentDirGuard { + fn enter(path: &Path) -> Self { + let original = std::env::current_dir().expect("read current dir"); + std::env::set_current_dir(path).expect("set current dir"); + Self { original } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.original); + } + } + + fn write_plugin(parent: &Path, id: &str, name: &str) { + let plugin_dir = parent.join(id); + write_plugin_at(&plugin_dir, id, name); + } + + fn write_plugin_at(plugin_dir: &Path, id: &str, name: &str) { + fs::create_dir_all(plugin_dir).expect("create plugin dir"); + fs::write( + plugin_dir.join("plugin.json"), + format!( + r##"{{ + "schemaVersion": 1, + "id": "{}", + "name": "{}", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.svg", + "brandColor": "#000000", + "lines": [] +}}"##, + id, name + ), + ) + .expect("write plugin manifest"); + fs::write( + plugin_dir.join("plugin.js"), + format!( + r#"globalThis.__openusage_plugin = {{ id: "{}", probe: () => ({{ lines: [] }}) }}"#, + id + ), + ) + .expect("write plugin script"); + fs::write( + plugin_dir.join("icon.svg"), + r#""#, + ) + .expect("write plugin icon"); + } + + #[test] + #[serial] + fn initialize_plugins_removes_retired_windsurf_without_removing_custom_plugins() { + let root = TempDir::new("retired"); + let _cwd = CurrentDirGuard::enter(root.path()); + let app_data_dir = root.path().join("app-data"); + let install_dir = app_data_dir.join("plugins"); + let resource_dir = root.path().join("resources"); + let bundled_dir = resource_dir.join("bundled_plugins"); + + write_plugin(&install_dir, "windsurf", "Windsurf"); + write_plugin(&install_dir, "custom", "Custom"); + write_plugin(&bundled_dir, "devin", "Devin"); + + let (loaded_dir, plugins) = initialize_plugins(&app_data_dir, Some(&resource_dir)); + let ids: Vec<_> = plugins + .iter() + .map(|plugin| plugin.manifest.id.as_str()) + .collect(); + + assert_eq!(loaded_dir, install_dir); + assert!(!loaded_dir.join("windsurf").exists()); + assert!(loaded_dir.join("custom").exists()); + assert!(loaded_dir.join("devin").exists()); + assert_eq!(ids, vec!["custom", "devin"]); + } + + #[test] + #[serial] + fn initialize_plugins_skips_retired_plugin_even_when_cleanup_does_not_remove_it() { + let root = TempDir::new("retired-skip"); + let _cwd = CurrentDirGuard::enter(root.path()); + let app_data_dir = root.path().join("app-data"); + let install_dir = app_data_dir.join("plugins"); + let resource_dir = root.path().join("resources"); + fs::create_dir_all(&resource_dir).expect("create resource dir"); + + let mismatched_dir = install_dir.join("legacy-name"); + write_plugin_at(&mismatched_dir, "windsurf", "Windsurf"); + + let (_loaded_dir, plugins) = initialize_plugins(&app_data_dir, Some(&resource_dir)); + + assert!(mismatched_dir.exists()); + assert!(plugins.is_empty()); + } } diff --git a/crates/crossusage-core/src/plugin_engine/runtime.rs b/crates/crossusage-core/src/plugin_engine/runtime.rs index 4d735980..d348a2fc 100644 --- a/crates/crossusage-core/src/plugin_engine/runtime.rs +++ b/crates/crossusage-core/src/plugin_engine/runtime.rs @@ -178,6 +178,12 @@ fn run_probe_with_account_timeout( } return error_output(plugin, "cursorLogs wrapper patch failed".to_string()); } + if host_api::patch_cursor_usage_export_wrapper(&ctx).is_err() { + if deadline.has_elapsed() { + return error_output(plugin, timeout_message.clone()); + } + return error_output(plugin, "cursorUsageExport wrapper patch failed".to_string()); + } if host_api::patch_fireworks_wrapper(&ctx).is_err() { if deadline.has_elapsed() { return error_output(plugin, timeout_message.clone()); diff --git a/docs/local-http-api.md b/docs/local-http-api.md index 22a084b1..1089eff3 100644 --- a/docs/local-http-api.md +++ b/docs/local-http-api.md @@ -38,6 +38,20 @@ Returns a single cached usage snapshot for the given provider. - **204 No Content** — Provider is known but has no cached snapshot yet. - **404 Not Found** — Provider ID is unknown. +### `GET /v1/history/quota` + +Returns quota snapshot history from local SQLite when **Settings → Save usage snapshots** is enabled. + +- **200 OK** — JSON array (empty `[]` when persist is off or no rows). +- Query: `?limit=200` (default `80`, max `2000`). + +### `GET /v1/history/daily` + +Returns daily token rows from local SQLite (`usage_daily`). + +- **200 OK** — JSON array (empty `[]` when persist is off or no rows). +- Query: `?limit=120` (default `120`, max `2000`). + ### Unsupported methods Any method other than `GET` or `OPTIONS` on the above routes returns **405 Method Not Allowed**. diff --git a/docs/providers/cursor.md b/docs/providers/cursor.md index 61c5e162..0a788e51 100644 --- a/docs/providers/cursor.md +++ b/docs/providers/cursor.md @@ -21,6 +21,7 @@ | Auto usage | `planUsage.autoPercentUsed` | detail | percent | Omitted when field is missing or non-finite | | API usage | `planUsage.apiPercentUsed` | detail | percent | Omitted when field is missing or non-finite | | Requests | `/api/usage` (enterprise) | overview | count | Enterprise accounts only; unchanged from previous behavior | +| MTD usage | `GET /api/dashboard/export-usage-events-csv` | overview | text | Month-to-date tokens and cost from Cursor dashboard billing export (cached ~45 min). Complements transcript-based **Activity trend**. | | On-demand | `spendLimitUsage` | detail | dollars | Only when individual or pooled limit > 0 | **Enterprise flow** remains request-based via the REST `/api/usage` endpoint -- unchanged. @@ -153,7 +154,11 @@ OpenUsage reads Cursor auth in this order: #### 1) Cursor Desktop SQLite (preferred) -Path: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` +Path (stable): `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + +Path (Nightly, Linux): `~/.config/Cursor Nightly/User/globalStorage/state.vscdb` + +CrossUsage ships **two sidebar providers**: **Cursor** (stable) and **Cursor Nightly**. Each reads only its own `state.vscdb` — they are not merged. Enable **Cursor Nightly** in Settings if you use the nightly app. Override a single install: `CURSOR_STATE_DB=/path/to/state.vscdb`. ```bash sqlite3 ~/Library/Application\ Support/Cursor/User/globalStorage/state.vscdb \ @@ -251,7 +256,7 @@ For other **400/403** errors (without that detail), the same chain applies; the **If it still fails:** 1. Run **`agent login`** (CLI) *and* open the **Cursor** desktop app signed into the **same** account so `state.vscdb` tokens match, or sign out/in to refresh. -2. Ensure `~/.config/Cursor/User/globalStorage/state.vscdb` exists on Linux (or the macOS/Windows paths above). +2. Ensure `~/.config/Cursor/User/globalStorage/state.vscdb` or `~/.config/Cursor Nightly/User/globalStorage/state.vscdb` exists on Linux (or the macOS/Windows paths above). 3. Update Cursor to the latest version — their Connect API can change. CrossUsage’s **CLI header “host CPU / MEM”** is your **PC’s** CPU and RAM via `sysinfo`; it is **not** Cursor cloud usage. diff --git a/docs/providers/multi-account-credentials.md b/docs/providers/multi-account-credentials.md index 7931cae8..0f00f7fb 100644 --- a/docs/providers/multi-account-credentials.md +++ b/docs/providers/multi-account-credentials.md @@ -27,7 +27,7 @@ You need values that match what Cursor stores after a normal login. 1. **Sign in** to the Cursor account you want in the **Cursor** desktop app (one login per OS user profile is typical). 2. Find `**state.vscdb`** (VS Code global storage): - **macOS:** `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` - - **Linux:** `~/.config/Cursor/User/globalStorage/state.vscdb` + - **Linux:** `~/.config/Cursor/User/globalStorage/state.vscdb` or `~/.config/Cursor Nightly/User/globalStorage/state.vscdb` - **Windows:** `%APPDATA%\Cursor\User\globalStorage\state.vscdb` 3. Read the keys (requires `sqlite3` installed, or a GUI SQLite tool): ```bash diff --git a/package.json b/package.json index 8291be9a..6e7ac61d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "crossusage", "private": true, - "version": "1.0.11", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", @@ -37,6 +37,8 @@ "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-clipboard-manager": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", + "@tauri-apps/plugin-fs": "^2.5.1", "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", diff --git a/plugins/cursor-nightly/icon.png b/plugins/cursor-nightly/icon.png new file mode 100644 index 00000000..737710fc Binary files /dev/null and b/plugins/cursor-nightly/icon.png differ diff --git a/plugins/cursor-nightly/plugin.js b/plugins/cursor-nightly/plugin.js new file mode 100644 index 00000000..c0aaa88d --- /dev/null +++ b/plugins/cursor-nightly/plugin.js @@ -0,0 +1,1412 @@ +globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ = "cursor-nightly"; +(function () { + const KEYCHAIN_ACCESS_TOKEN_SERVICE = "cursor-access-token" + const KEYCHAIN_REFRESH_TOKEN_SERVICE = "cursor-refresh-token" + const BASE_URL = "https://api2.cursor.sh" + const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage" + const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo" + const REFRESH_URL = BASE_URL + "/oauth/token" + const CREDITS_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCreditGrantsBalance" + const USAGE_LIMIT_GRANTS_URL = + BASE_URL + "/aiserver.v1.DashboardService/GetUsageLimitStatusAndActiveGrants" + const REST_USAGE_URL = "https://cursor.com/api/usage" + const STRIPE_URL = "https://cursor.com/api/auth/stripe" + const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" + /** Must match `buildDevMockProviderCredentials` when `VITE_PROVIDER_ACCOUNT_DEV_MOCK` is enabled. */ + const CROSSUSAGE_DEV_MOCK_ACCESS = "crossusage-dev-mock-access-token" + const CROSSUSAGE_DEV_MOCK_REFRESH = "crossusage-dev-mock-refresh-token" + const CROSSUSAGE_DEV_MOCK_SESSION_PREFIX = "crossusage-dev-mock-session:" + const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration + const LOGIN_HINT = "Sign in via Cursor app or run `agent login`." + /** Connect RPC (api2.cursor.sh) — short product UA. */ + const CONNECT_CLIENT_USER_AGENT = "Cursor/1.0.0" + /** cursor.com web dashboard style (some endpoints reject non-browser UAs). */ + const CURSOR_WEB_USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + const CONNECT_USAGE_FALLBACK_FAILED_MSG = + "Cursor Connect API rejected usage (HTTP 400/403), and cursor.com/api/usage had no usable data " + + "(no request limits or plan usage). Open Cursor, sign in again, update the app, then retry. " + + LOGIN_HINT + + function extractConnectUsageErrorDetail(ctx, bodyText) { + var j = ctx.util.tryParseJson(bodyText) + if (!j || typeof j !== "object") return null + if (typeof j.message === "string" && j.message.length > 0 && j.message !== "Error") { + return j.message.trim() + } + if (Array.isArray(j.details)) { + for (var i = 0; i < j.details.length; i++) { + var d = j.details[i] + if (!d || typeof d !== "object") continue + if (d.debug && d.debug.details && typeof d.debug.details.detail === "string") { + return d.debug.details.detail.trim() + } + if (d.debug && typeof d.debug.detail === "string") return d.debug.detail.trim() + } + } + return null + } + + function buildConnectFallbackFailedMessage(connectDetail) { + if ( + connectDetail && + String(connectDetail).indexOf("Usage summary is not enabled") >= 0 + ) { + return ( + "Cursor reports \"Usage summary is not enabled\" for this account (GetCurrentPeriodUsage is gated off). " + + "CrossUsage tried alternate APIs and cursor.com; check usage in the Cursor app (Settings → Account). " + + LOGIN_HINT + ) + } + return CONNECT_USAGE_FALLBACK_FAILED_MSG + } + + /** + * Map undocumented GetUsageLimitStatusAndActiveGrants JSON toward GetCurrentPeriodUsage shape. + */ + function normalizeLimitGrantsToUsageShape(raw) { + if (!raw || typeof raw !== "object") return null + var u = raw + if (u.data && typeof u.data === "object") u = u.data + if (u.result && typeof u.result === "object" && !u.planUsage && u.result.planUsage) { + u = u.result + } + if (u.usage && typeof u.usage === "object") { + if (u.usage.planUsage && !u.planUsage) { + u = Object.assign({}, u.usage, { planUsage: u.usage.planUsage }) + } else if (!u.planUsage) { + u = u.usage + } + } + if (u.planUsage && typeof u.planUsage === "object") { + coerceUsageNumbers(u) + return u + } + if (u.limitStatus && typeof u.limitStatus === "object") { + var ls = u.limitStatus + var pu = {} + if (ls.limit != null) pu.limit = ls.limit + if (ls.remaining != null) pu.remaining = ls.remaining + if (ls.totalSpend != null) pu.totalSpend = ls.totalSpend + if (ls.totalPercentUsed != null) pu.totalPercentUsed = ls.totalPercentUsed + if (Object.keys(pu).length > 0) { + return { enabled: true, planUsage: pu } + } + } + var pct = readFiniteNumber(u.totalPercentUsed) + if (Number.isFinite(pct)) { + return { enabled: true, planUsage: { totalPercentUsed: pct } } + } + return null + } + + const CURSOR_STATE_DB_REL = "Cursor/User/globalStorage/state.vscdb" + const CURSOR_NIGHTLY_STATE_DB_REL = "Cursor Nightly/User/globalStorage/state.vscdb" + const CURSOR_STATE_DB_FALLBACK = + "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + + function cursorBaseProviderId(ctx) { + if (ctx.account && ctx.account.baseProviderId) { + return String(ctx.account.baseProviderId) + } + return globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ || "cursor" + } + + function getCursorDbPath(ctx) { + try { + if ( + ctx.host.cursorPaths && + typeof ctx.host.cursorPaths.resolveStateDb === "function" + ) { + var resolved = ctx.host.cursorPaths.resolveStateDb() + if (resolved) return resolved + } + } catch (e) { + ctx.host.log.warn("cursorPaths.resolveStateDb failed: " + String(e)) + } + if ( + ctx.host.fs && + typeof ctx.host.fs.firstExistingAppSupport === "function" + ) { + var rel = + cursorBaseProviderId(ctx) === "cursor-nightly" + ? CURSOR_NIGHTLY_STATE_DB_REL + : CURSOR_STATE_DB_REL + var found = ctx.host.fs.firstExistingAppSupport(rel) + if (found) return found + } + return CURSOR_STATE_DB_FALLBACK + } + + function readStateValue(ctx, key) { + try { + const dbPath = getCursorDbPath(ctx) + const sql = + "SELECT value FROM ItemTable WHERE key = '" + key + "' LIMIT 1;" + const json = ctx.host.sqlite.query(dbPath, sql) + const rows = ctx.util.tryParseJson(json) + if (!Array.isArray(rows)) { + throw new Error("sqlite returned invalid json") + } + if (rows.length > 0 && rows[0].value) { + return rows[0].value + } + } catch (e) { + ctx.host.log.warn("sqlite read failed for " + key + ": " + String(e)) + } + return null + } + + function writeStateValue(ctx, key, value) { + try { + const dbPath = getCursorDbPath(ctx) + // Escape single quotes in value for SQL + const escaped = String(value).replace(/'/g, "''") + const sql = + "INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('" + + key + + "', '" + + escaped + + "');" + ctx.host.sqlite.exec(dbPath, sql) + return true + } catch (e) { + ctx.host.log.warn("sqlite write failed for " + key + ": " + String(e)) + return false + } + } + + function readKeychainValue(ctx, service) { + if (!ctx.host.keychain || typeof ctx.host.keychain.readGenericPassword !== "function") { + return null + } + try { + const value = ctx.host.keychain.readGenericPassword(service) + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed || null + } catch (e) { + ctx.host.log.info("keychain read failed for " + service + ": " + String(e)) + return null + } + } + + function writeKeychainValue(ctx, service, value) { + if (!ctx.host.keychain || typeof ctx.host.keychain.writeGenericPassword !== "function") { + ctx.host.log.warn("keychain write unsupported") + return false + } + try { + ctx.host.keychain.writeGenericPassword(service, String(value)) + return true + } catch (e) { + ctx.host.log.warn("keychain write failed for " + service + ": " + String(e)) + return false + } + } + + function loadAuthState(ctx) { + const injected = readInjectedCredential(ctx) + if (injected) return injected + + const sqliteAccessToken = readStateValue(ctx, "cursorAuth/accessToken") + const sqliteRefreshToken = readStateValue(ctx, "cursorAuth/refreshToken") + const sqliteMembershipTypeRaw = readStateValue(ctx, "cursorAuth/stripeMembershipType") + const sqliteMembershipType = typeof sqliteMembershipTypeRaw === "string" + ? sqliteMembershipTypeRaw.trim().toLowerCase() + : null + + const keychainAccessToken = readKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE) + const keychainRefreshToken = readKeychainValue(ctx, KEYCHAIN_REFRESH_TOKEN_SERVICE) + + const sqliteSubject = getTokenSubject(ctx, sqliteAccessToken) + const keychainSubject = getTokenSubject(ctx, keychainAccessToken) + const hasDifferentSubjects = !!sqliteSubject && !!keychainSubject && sqliteSubject !== keychainSubject + const sqliteLooksFree = sqliteMembershipType === "free" + + if (sqliteAccessToken || sqliteRefreshToken) { + if ((keychainAccessToken || keychainRefreshToken) && sqliteLooksFree && hasDifferentSubjects) { + ctx.host.log.info("sqlite auth looks free and differs from keychain account; preferring keychain token") + return { + accessToken: keychainAccessToken, + refreshToken: keychainRefreshToken, + source: "keychain", + } + } + + return { + accessToken: sqliteAccessToken, + refreshToken: sqliteRefreshToken, + source: "sqlite", + } + } + + if (keychainAccessToken || keychainRefreshToken) { + return { + accessToken: keychainAccessToken, + refreshToken: keychainRefreshToken, + source: "keychain", + } + } + + return { + accessToken: null, + refreshToken: null, + source: null, + } + } + + function readInjectedCredential(ctx) { + try { + if (!ctx.host.credentials || typeof ctx.host.credentials.get !== "function") return null + const raw = ctx.host.credentials.get() + if (!raw) return null + const credential = ctx.util.tryParseJson(String(raw)) + if (!credential) return null + const accessToken = String(credential.accessToken || credential.sessionKey || "").trim() + const refreshToken = String(credential.refreshToken || "").trim() + if (!accessToken && !refreshToken) return null + return { + accessToken: accessToken || null, + refreshToken: refreshToken || null, + source: "provider-account", + } + } catch (e) { + ctx.host.log.warn("provider account credential read failed: " + String(e)) + return null + } + } + + function isCrossusageDevMockCredential(accessToken, refreshToken) { + const at = accessToken ? String(accessToken).trim() : "" + const rt = refreshToken ? String(refreshToken).trim() : "" + if (at === CROSSUSAGE_DEV_MOCK_ACCESS) return true + if (rt === CROSSUSAGE_DEV_MOCK_REFRESH) return true + if (at.indexOf(CROSSUSAGE_DEV_MOCK_SESSION_PREFIX) === 0) return true + return false + } + + function buildCrossusageDevMockProbeOutput(ctx) { + ctx.host.log.info("crossusage dev mock: skipping Cursor API (no network)") + // Labels must match plugin.json manifest lines so tray + overview filters never go empty. + return { + plan: "Dev mock", + lines: [ + ctx.line.text({ + label: "Data source", + value: "Mock (CrossUsage dev)", + subtitle: "Placeholder tokens from Settings; api2.cursor.sh is not called.", + }), + ctx.line.progress({ + label: "Total usage", + used: 35, + limit: 100, + format: { kind: "percent" }, + }), + ctx.line.progress({ + label: "Credits", + used: 250, + limit: 1000, + format: { kind: "dollars" }, + }), + ctx.line.progress({ + label: "Requests", + used: 42, + limit: 1000, + format: { kind: "count", suffix: " req" }, + }), + ctx.line.progress({ + label: "Auto usage", + used: 18, + limit: 100, + format: { kind: "percent" }, + }), + ctx.line.progress({ + label: "API usage", + used: 7, + limit: 50, + format: { kind: "percent" }, + }), + ctx.line.progress({ + label: "On-demand", + used: 1, + limit: 10, + format: { kind: "percent" }, + }), + ], + } + } + + function getTokenSubject(ctx, token) { + if (!token) return null + const payload = ctx.jwt.decodePayload(token) + if (!payload || typeof payload.sub !== "string") return null + const subject = payload.sub.trim() + return subject || null + } + + function persistAccessToken(ctx, source, accessToken) { + if (source === "provider-account") { + try { + if (ctx.host.credentials && typeof ctx.host.credentials.update === "function") { + const update = { accessToken: accessToken || null } + ctx.host.credentials.update(JSON.stringify(update)) + return true + } + } catch (e) { + ctx.host.log.warn("provider account credential update failed: " + String(e)) + } + return false + } + if (source === "keychain") { + return writeKeychainValue(ctx, KEYCHAIN_ACCESS_TOKEN_SERVICE, accessToken) + } + return writeStateValue(ctx, "cursorAuth/accessToken", accessToken) + } + + function getTokenExpiration(ctx, token) { + const payload = ctx.jwt.decodePayload(token) + if (!payload || typeof payload.exp !== "number") return null + return payload.exp * 1000 // Convert to milliseconds + } + + function needsRefresh(ctx, accessToken, nowMs) { + if (!accessToken) return true + const expiresAt = getTokenExpiration(ctx, accessToken) + return ctx.util.needsRefreshByExpiry({ + nowMs, + expiresAtMs: expiresAt, + bufferMs: REFRESH_BUFFER_MS, + }) + } + + function refreshToken(ctx, refreshTokenValue, source) { + if (!refreshTokenValue) { + ctx.host.log.warn("refresh skipped: no refresh token") + return null + } + + ctx.host.log.info("attempting token refresh") + try { + const resp = ctx.util.request({ + method: "POST", + url: REFRESH_URL, + headers: { "Content-Type": "application/json" }, + bodyText: JSON.stringify({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshTokenValue, + }), + timeoutMs: 15000, + }) + + if (resp.status === 400 || resp.status === 401) { + let errorInfo = null + errorInfo = ctx.util.tryParseJson(resp.bodyText) + const shouldLogout = errorInfo && errorInfo.shouldLogout === true + ctx.host.log.error("refresh failed: status=" + resp.status + " shouldLogout=" + shouldLogout) + if (shouldLogout) { + throw "Session expired. " + LOGIN_HINT + } + throw "Token expired. " + LOGIN_HINT + } + + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("refresh returned unexpected status: " + resp.status) + return null + } + + const body = ctx.util.tryParseJson(resp.bodyText) + if (!body) { + ctx.host.log.warn("refresh response not valid JSON") + return null + } + + // Check if server wants us to logout + if (body.shouldLogout === true) { + ctx.host.log.error("refresh response indicates shouldLogout=true") + throw "Session expired. " + LOGIN_HINT + } + + const newAccessToken = body.access_token + if (!newAccessToken) { + ctx.host.log.warn("refresh response missing access_token") + return null + } + + // Persist updated access token to source where auth was loaded from. + persistAccessToken(ctx, source, newAccessToken) + ctx.host.log.info("refresh succeeded, token persisted") + + // Note: Cursor refresh returns access_token which is used as both + // access and refresh token in some flows + return newAccessToken + } catch (e) { + if (typeof e === "string") throw e + ctx.host.log.error("refresh exception: " + String(e)) + return null + } + } + + function connectPost(ctx, url, token) { + return ctx.util.request({ + method: "POST", + url: url, + headers: { + Authorization: "Bearer " + token, + "Content-Type": "application/json", + "Connect-Protocol-Version": "1", + Accept: "application/json", + "User-Agent": CONNECT_CLIENT_USER_AGENT, + }, + bodyText: "{}", + timeoutMs: 10000, + }) + } + + /** Auth0-style `sub` is often `provider|opaqueId`; use last segment so `a|b|c` maps to `c`. */ + function userIdFromJwtSub(sub) { + if (!sub || typeof sub !== "string") return null + var parts = String(sub).trim().split("|") + if (parts.length >= 2) { + var last = parts[parts.length - 1] + return last ? last.trim() : parts[0].trim() + } + return parts[0] ? parts[0].trim() : null + } + + function buildSessionToken(ctx, accessToken) { + var payload = ctx.jwt.decodePayload(accessToken) + if (!payload || !payload.sub) return null + var userId = userIdFromJwtSub(payload.sub) + if (!userId) return null + return { userId: userId, sessionToken: userId + "%3A%3A" + accessToken } + } + + function coerceNumericField(obj, key) { + if (!obj || typeof obj !== "object") return + var v = obj[key] + if (typeof v === "string" && v.trim() !== "") { + var n = parseFloat(v) + if (Number.isFinite(n)) obj[key] = n + } + } + + function coercePlanUsageObject(pu) { + if (!pu || typeof pu !== "object") return + var keys = [ + "limit", + "remaining", + "totalSpend", + "includedSpend", + "bonusSpend", + "totalPercentUsed", + "autoPercentUsed", + "apiPercentUsed", + ] + for (var i = 0; i < keys.length; i++) { + coerceNumericField(pu, keys[i]) + } + } + + function coerceUsageNumbers(u) { + if (!u || typeof u !== "object") return + coerceNumericField(u, "billingCycleStart") + coerceNumericField(u, "billingCycleEnd") + if (u.planUsage) coercePlanUsageObject(u.planUsage) + if (u.spendLimitUsage && typeof u.spendLimitUsage === "object") { + var su = u.spendLimitUsage + coerceNumericField(su, "individualLimit") + coerceNumericField(su, "individualUsed") + coerceNumericField(su, "individualRemaining") + coerceNumericField(su, "pooledLimit") + coerceNumericField(su, "pooledUsed") + coerceNumericField(su, "pooledRemaining") + } + } + + /** Unwrap `{ data: ... }` / `{ usage: ... }` and coerce numeric strings from the dashboard API. */ + function normalizeRestUsagePayload(raw) { + if (!raw || typeof raw !== "object") return null + var u = raw + if (u.data && typeof u.data === "object") u = u.data + if (u.result && typeof u.result === "object" && !u.planUsage && u.result.planUsage) { + u = u.result + } else if (u.usage && typeof u.usage === "object" && !u.planUsage && u.usage.planUsage) { + u = u.usage + } + coerceUsageNumbers(u) + return u + } + + function restUsageLooksPromising(u) { + if (!u || typeof u !== "object") return false + var g4 = u["gpt-4"] + if (g4 && typeof g4.maxRequestUsage === "number" && g4.maxRequestUsage > 0) return true + if (isConnectUsageRestShape(u)) return true + return false + } + + function fetchRequestBasedUsage(ctx, accessToken) { + var session = buildSessionToken(ctx, accessToken) + if (!session) { + ctx.host.log.warn("request-based: cannot build session token") + return null + } + var commonHeaders = { + Authorization: "Bearer " + accessToken, + Cookie: "WorkosCursorSessionToken=" + session.sessionToken, + Accept: "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard", + "User-Agent": CURSOR_WEB_USER_AGENT, + } + var urls = [ + REST_USAGE_URL + "?user=" + encodeURIComponent(session.userId), + REST_USAGE_URL, + ] + var fallback = null + for (var i = 0; i < urls.length; i++) { + try { + var resp = ctx.util.request({ + method: "GET", + url: urls[i], + headers: commonHeaders, + timeoutMs: 15000, + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn( + "request-based usage returned status=" + resp.status + " url=" + urls[i] + ) + continue + } + var parsed = ctx.util.tryParseJson(resp.bodyText) + var norm = normalizeRestUsagePayload(parsed) + if (norm && restUsageLooksPromising(norm)) { + return norm + } + if (norm && !fallback) fallback = norm + } catch (e) { + ctx.host.log.warn("request-based usage fetch failed: " + String(e)) + } + } + return fallback + } + + function fetchStripePayload(ctx, accessToken) { + var session = buildSessionToken(ctx, accessToken) + if (!session) { + ctx.host.log.warn("stripe: cannot build session token") + return null + } + try { + var resp = ctx.util.request({ + method: "GET", + url: STRIPE_URL, + headers: { + Authorization: "Bearer " + accessToken, + Cookie: "WorkosCursorSessionToken=" + session.sessionToken, + Accept: "application/json", + Origin: "https://cursor.com", + Referer: "https://cursor.com/dashboard", + "User-Agent": CURSOR_WEB_USER_AGENT, + }, + timeoutMs: 10000, + }) + if (resp.status < 200 || resp.status >= 300) { + ctx.host.log.warn("stripe payload returned status=" + resp.status) + return null + } + return ctx.util.tryParseJson(resp.bodyText) + } catch (e) { + ctx.host.log.warn("stripe payload fetch failed: " + String(e)) + return null + } + } + + function fetchStripeBalance(ctx, accessToken) { + var stripe = fetchStripePayload(ctx, accessToken) + if (!stripe) return null + var customerBalanceCents = Number(stripe.customerBalance) + if (!Number.isFinite(customerBalanceCents)) return null + // Stripe stores customer credits as a negative balance. + return customerBalanceCents < 0 ? Math.abs(customerBalanceCents) : 0 + } + + function buildPartialStripeSubscriptionResult(ctx, accessToken, connectDetail) { + var stripe = fetchStripePayload(ctx, accessToken) + if (!stripe || typeof stripe !== "object") return null + var mt = stripe.membershipType + var ss = stripe.subscriptionStatus + if (!mt && !ss) return null + var tier = typeof mt === "string" ? mt : "unknown" + var sub = typeof ss === "string" ? ss : "unknown" + var msg = "Plan: " + tier + ", subscription: " + sub + ". " + if (connectDetail && String(connectDetail).indexOf("Usage summary is not enabled") >= 0) { + msg += + "Cursor does not expose usage summary for this account via API. Open Cursor → Settings → Account to see usage." + } else { + msg += "Usage meters unavailable from API; open Cursor → Account / Usage." + } + return { + plan: null, + lines: [ + ctx.line.text({ + label: "Account", + value: msg, + }), + ], + } + } + + function readFiniteNumber(v) { + if (typeof v === "number" && Number.isFinite(v)) return v + if (typeof v === "string" && v.trim() !== "") { + var n = parseFloat(v) + if (Number.isFinite(n)) return n + } + return NaN + } + + function getConnectUsageMetricFlags(usage) { + const hasPlanUsage = !!usage.planUsage + const pu = usage.planUsage + const limitN = hasPlanUsage ? readFiniteNumber(pu.limit) : NaN + const pctN = hasPlanUsage ? readFiniteNumber(pu.totalPercentUsed) : NaN + const hasPlanUsageLimit = hasPlanUsage && Number.isFinite(limitN) + const planUsageLimitMissing = hasPlanUsage && !hasPlanUsageLimit + const hasTotalUsagePercent = hasPlanUsage && Number.isFinite(pctN) + return { + hasPlanUsage: hasPlanUsage, + hasPlanUsageLimit: hasPlanUsageLimit, + planUsageLimitMissing: planUsageLimitMissing, + hasTotalUsagePercent: hasTotalUsagePercent, + pu: pu, + } + } + + /** + * Build plan label + progress lines from a Connect-shaped usage object (same JSON as + * GetCurrentPeriodUsage). Returns null when a team account needs the legacy request-based REST path. + */ + function buildPlanAndLinesFromConnectStyleUsage(ctx, usage, planName, options) { + options = options || {} + const creditGrants = options.creditGrants + const stripeBalanceCents = options.stripeBalanceCents || 0 + + coerceUsageNumbers(usage) + + if (usage.enabled === false || !usage.planUsage) { + throw "No active Cursor subscription." + } + + const normalizedPlanName = typeof planName === "string" + ? planName.toLowerCase() + : "" + + const flags = getConnectUsageMetricFlags(usage) + const hasPlanUsageLimit = flags.hasPlanUsageLimit + const hasTotalUsagePercent = flags.hasTotalUsagePercent + const pu = flags.pu + + if (!hasPlanUsageLimit && !hasTotalUsagePercent) { + throw "Total usage limit missing from API response." + } + + let plan = null + if (planName) { + const planLabel = ctx.fmt.planLabel(planName) + if (planLabel) { + plan = planLabel + } + } + + const lines = [] + + const hasCreditGrants = creditGrants && creditGrants.hasCreditGrants === true + const grantTotalCents = hasCreditGrants ? parseInt(creditGrants.totalCents, 10) : 0 + const grantUsedCents = hasCreditGrants ? parseInt(creditGrants.usedCents, 10) : 0 + const hasValidGrantData = hasCreditGrants && + grantTotalCents > 0 && + !isNaN(grantTotalCents) && + !isNaN(grantUsedCents) + const combinedTotalCents = (hasValidGrantData ? grantTotalCents : 0) + stripeBalanceCents + + if (combinedTotalCents > 0) { + lines.push(ctx.line.progress({ + label: "Credits", + used: ctx.fmt.dollars(hasValidGrantData ? grantUsedCents : 0), + limit: ctx.fmt.dollars(combinedTotalCents), + format: { kind: "dollars" }, + })) + } + + const planUsed = hasPlanUsageLimit + ? (typeof pu.totalSpend === "number" + ? pu.totalSpend + : pu.limit - (pu.remaining ?? 0)) + : 0 + const computedPercentUsed = hasPlanUsageLimit && pu.limit > 0 + ? (planUsed / pu.limit) * 100 + : 0 + const totalUsagePercent = hasTotalUsagePercent + ? pu.totalPercentUsed + : computedPercentUsed + + var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 + var cycleStart = Number(usage.billingCycleStart) + var cycleEnd = Number(usage.billingCycleEnd) + if (Number.isFinite(cycleStart) && Number.isFinite(cycleEnd) && cycleEnd > cycleStart) { + billingPeriodMs = cycleEnd - cycleStart + } + + const su = usage.spendLimitUsage + const isTeamAccount = ( + normalizedPlanName === "team" || + (su && su.limitType === "team") || + (su && typeof su.pooledLimit === "number" && su.pooledLimit > 0) + ) + + if (isTeamAccount) { + if (!hasPlanUsageLimit) { + return null + } + lines.push(ctx.line.progress({ + label: "Total usage", + used: ctx.fmt.dollars(planUsed), + limit: ctx.fmt.dollars(pu.limit), + format: { kind: "dollars" }, + resetsAt: ctx.util.toIso(usage.billingCycleEnd), + periodDurationMs: billingPeriodMs, + })) + + if (typeof pu.bonusSpend === "number" && pu.bonusSpend > 0) { + lines.push(ctx.line.text({ label: "Bonus spend", value: "$" + String(ctx.fmt.dollars(pu.bonusSpend)) })) + } + } else { + lines.push(ctx.line.progress({ + label: "Total usage", + used: totalUsagePercent, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(usage.billingCycleEnd), + periodDurationMs: billingPeriodMs, + })) + } + + if (typeof pu.autoPercentUsed === "number" && Number.isFinite(pu.autoPercentUsed)) { + lines.push(ctx.line.progress({ + label: "Auto usage", + used: pu.autoPercentUsed, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(usage.billingCycleEnd), + periodDurationMs: billingPeriodMs, + })) + } + + if (typeof pu.apiPercentUsed === "number" && Number.isFinite(pu.apiPercentUsed)) { + lines.push(ctx.line.progress({ + label: "API usage", + used: pu.apiPercentUsed, + limit: 100, + format: { kind: "percent" }, + resetsAt: ctx.util.toIso(usage.billingCycleEnd), + periodDurationMs: billingPeriodMs, + })) + } + + if (su) { + const limit = su.individualLimit ?? su.pooledLimit ?? 0 + const remaining = su.individualRemaining ?? su.pooledRemaining ?? 0 + if (limit > 0) { + const used = limit - remaining + lines.push(ctx.line.progress({ + label: "On-demand", + used: ctx.fmt.dollars(used), + limit: ctx.fmt.dollars(limit), + format: { kind: "dollars" }, + })) + } + } + + return { plan: plan, lines: lines } + } + + function finalizePlanResult(ctx, planName, lines) { + var plan = null + if (planName) { + var planLabel = ctx.fmt.planLabel(planName) + if (planLabel) plan = planLabel + } + return { plan: plan, lines: lines } + } + + /** True when REST /api/usage JSON looks like GetCurrentPeriodUsage (not gpt-4-only enterprise payload). */ + function isConnectUsageRestShape(u) { + if (!u || typeof u !== "object") return false + return ( + u.planUsage != null || + typeof u.enabled === "boolean" || + u.billingCycleStart != null || + u.billingCycleEnd != null || + u.spendLimitUsage != null || + typeof u.displayMessage === "string" || + typeof u.displayThreshold === "number" + ) + } + + function buildRequestBasedResult(ctx, accessToken, planName, unavailableMessage) { + var requestUsage = fetchRequestBasedUsage(ctx, accessToken) + var lines = [] + + if (requestUsage) { + var gpt4 = requestUsage["gpt-4"] + if (gpt4 && typeof gpt4.maxRequestUsage === "number" && gpt4.maxRequestUsage > 0) { + var used = gpt4.numRequests || 0 + var limit = gpt4.maxRequestUsage + + var billingPeriodMs = 30 * 24 * 60 * 60 * 1000 + var cycleStart = requestUsage.startOfMonth + ? ctx.util.parseDateMs(requestUsage.startOfMonth) + : null + var cycleEndMs = cycleStart ? cycleStart + billingPeriodMs : null + + lines.push(ctx.line.progress({ + label: "Requests", + used: used, + limit: limit, + format: { kind: "count", suffix: "requests" }, + resetsAt: ctx.util.toIso(cycleEndMs), + periodDurationMs: billingPeriodMs, + })) + } + } + + if (lines.length > 0) { + return finalizePlanResult(ctx, planName, lines) + } + + if (requestUsage && isConnectUsageRestShape(requestUsage)) { + var stripeBalanceCents = fetchStripeBalance(ctx, accessToken) || 0 + var creditGrants = null + try { + const creditsResp = connectPost(ctx, CREDITS_URL, accessToken) + if (creditsResp.status >= 200 && creditsResp.status < 300) { + creditGrants = ctx.util.tryParseJson(creditsResp.bodyText) + } + } catch (e) { + ctx.host.log.warn("request-based: credit grants fetch failed: " + String(e)) + } + try { + var connectStyle = buildPlanAndLinesFromConnectStyleUsage(ctx, requestUsage, planName, { + creditGrants: creditGrants, + stripeBalanceCents: stripeBalanceCents, + }) + if (connectStyle) { + return connectStyle + } + } catch (e) { + if (typeof e === "string") { + if (e === "No active Cursor subscription." || e === "Total usage limit missing from API response.") { + throw e + } + ctx.host.log.warn("request-based: connect-style parse failed: " + e) + } else { + ctx.host.log.warn("request-based: connect-style parse failed: " + String(e)) + } + } + } + + ctx.host.log.warn("request-based: no usage data available") + throw unavailableMessage + } + + function buildEnterpriseResult(ctx, accessToken, planName) { + return buildRequestBasedResult( + ctx, + accessToken, + planName, + "Enterprise usage data unavailable. Try again later." + ) + } + + function buildTeamRequestBasedResult(ctx, accessToken, planName) { + return buildRequestBasedResult( + ctx, + accessToken, + planName, + "Team request-based usage data unavailable. Try again later." + ) + } + + function buildUnknownRequestBasedResult(ctx, accessToken, planName) { + return buildRequestBasedResult( + ctx, + accessToken, + planName, + "Cursor request-based usage data unavailable. Try again later." + ) + } + + function cursorYyyymmdd(d) { + var y = d.getFullYear() + var m = String(d.getMonth() + 1).padStart(2, "0") + var day = String(d.getDate()).padStart(2, "0") + return String(y) + m + day + } + + function cursorSince31DaysAgo() { + var d = new Date() + d.setDate(d.getDate() - 31) + return cursorYyyymmdd(d) + } + + function cursorUntilToday() { + return cursorYyyymmdd(new Date()) + } + + function cursorAccountDisplayName(ctx) { + var base = cursorBaseProviderId(ctx) + var name = base === "cursor-nightly" ? "Cursor Nightly" : "Cursor" + var label = ctx.account && ctx.account.label ? String(ctx.account.label).trim() : "" + if (label) return name + " (" + label + ")" + return name + } + + function dayKeyFromCursorDate(rawDate) { + if (!rawDate) return null + var s = String(rawDate).trim() + if (s.length >= 10 && s.charAt(4) === "-") return s.slice(0, 10) + var digits = s.replace(/\D/g, "") + if (digits.length >= 8) { + return digits.slice(0, 4) + "-" + digits.slice(4, 6) + "-" + digits.slice(6, 8) + } + return null + } + + function cursorActivityDayLabel(rawDate) { + var key = dayKeyFromCursorDate(rawDate) + if (!key) return String(rawDate || "").slice(0, 10) || "Activity" + return Number(key.slice(5, 7)) + "/" + Number(key.slice(8, 10)) + } + + function fmtCursorTokens(n) { + var v = Number(n) + if (!Number.isFinite(v) || v < 0) return "0" + if (v >= 1_000_000) return (v / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M" + if (v >= 1_000) return (v / 1_000).toFixed(1).replace(/\.0$/, "") + "k" + return String(Math.round(v)) + } + + function queryCursorTranscriptDaily(ctx) { + if (!ctx.host.cursorLogs || typeof ctx.host.cursorLogs.queryDaily !== "function") return null + try { + var resp = ctx.host.cursorLogs.queryDaily({ since: cursorSince31DaysAgo() }) + if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) return null + return resp.data.daily + } catch (e) { + ctx.host.log.warn("cursor transcript daily query failed: " + String(e)) + return null + } + } + + function collectCursorActivityChartPoints(daily) { + var points = [] + for (var i = 0; i < daily.length; i++) { + var day = daily[i] + var tokens = Number(day && (day.totalTokens != null ? day.totalTokens : day.total_tokens)) + if (!Number.isFinite(tokens) || tokens <= 0) continue + var key = dayKeyFromCursorDate(day.date) + if (!key) continue + points.push({ + key: key, + label: cursorActivityDayLabel(day.date), + value: tokens, + valueLabel: fmtCursorTokens(tokens) + " tok (est.)", + }) + } + return points + .sort(function (a, b) { return a.key.localeCompare(b.key) }) + .slice(-31) + .map(function (point) { + return { + label: point.label, + value: point.value, + valueLabel: point.valueLabel, + } + }) + } + + function pushCursorActivityChartLine(lines, ctx, daily) { + var points = collectCursorActivityChartPoints(daily) + if (points.length === 0) return + lines.push(ctx.line.barChart({ + label: "Activity trend", + points: points, + note: "Estimated from local Cursor agent transcripts (not billing usage).", + color: "#6B7280", + })) + } + + function persistCursorUsageDaily(ctx, daily, displayName) { + if (!ctx.host.usageDaily || typeof ctx.host.usageDaily.ingest !== "function") return + if (!daily || !daily.length) return + try { + ctx.host.usageDaily.ingest({ + displayName: displayName, + source: "cursor_transcripts", + daily: daily, + }) + } catch (e) { /* ignore */ } + } + + function formatCompactTokens(n) { + var num = Number(n) || 0 + if (num >= 1000000) return (num / 1000000).toFixed(1) + "M tokens" + if (num >= 1000) return (num / 1000).toFixed(1) + "K tokens" + return String(num) + " tokens" + } + + function attachCursorMtdUsage(ctx, result) { + if (!result || !Array.isArray(result.lines)) return result + if (!ctx.host.cursorUsageExport || typeof ctx.host.cursorUsageExport.queryMtd !== "function") return result + try { + var resp = ctx.host.cursorUsageExport.queryMtd({}) + if (!resp || resp.status !== "ok" || !resp.data) return result + var d = resp.data + var inputTok = Number(d.inputTokens) || 0 + var outputTok = Number(d.outputTokens) || 0 + var total = d.totalTokens != null ? d.totalTokens : inputTok + outputTok + var value = formatCompactTokens(total) + if (inputTok > 0 || outputTok > 0) { + value = formatCompactTokens(inputTok) + " in · " + formatCompactTokens(outputTok) + " out" + if (d.costUsd != null && Number(d.costUsd) > 0) value += " · $" + Number(d.costUsd).toFixed(2) + } else if (d.costUsd != null && Number(d.costUsd) > 0) { + value += " · $" + Number(d.costUsd).toFixed(2) + } + result.lines.push(ctx.line.text({ + label: "MTD usage", + value: value, + subtitle: "From Cursor dashboard export (billing data)", + })) + } catch (e) { /* ignore */ } + return result + } + + function persistCursorBillingDaily(ctx, displayName) { + if (!ctx.host.cursorUsageExport || typeof ctx.host.cursorUsageExport.queryDaily !== "function") return + if (!ctx.host.usageDaily || typeof ctx.host.usageDaily.ingest !== "function") return + try { + var resp = ctx.host.cursorUsageExport.queryDaily({ + since: cursorSince31DaysAgo(), + until: cursorUntilToday(), + }) + if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) return + if (!resp.data.daily.length) return + var daily = resp.data.daily.map(function (row) { + return { + date: row.date, + totalTokens: row.totalTokens, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + costUsd: row.costUsd, + } + }) + ctx.host.usageDaily.ingest({ + displayName: displayName, + source: "cursor_billing", + daily: daily, + }) + } catch (e) { + ctx.host.log.warn("cursor billing daily ingest failed: " + String(e)) + } + } + + function attachCursorTranscriptActivity(ctx, result) { + if (!result || !Array.isArray(result.lines)) return result + var daily = queryCursorTranscriptDaily(ctx) + if (!daily || !daily.length) return result + var displayName = cursorAccountDisplayName(ctx) + persistCursorUsageDaily(ctx, daily, displayName) + pushCursorActivityChartLine(result.lines, ctx, daily) + return result + } + + function attachCursorBillingDaily(ctx, result) { + if (!result) return result + persistCursorBillingDaily(ctx, cursorAccountDisplayName(ctx)) + return result + } + + function probeImpl(ctx) { + const authState = loadAuthState(ctx) + let accessToken = authState.accessToken + const refreshTokenValue = authState.refreshToken + const authSource = authState.source + + if ( + authSource === "provider-account" && + isCrossusageDevMockCredential(accessToken, refreshTokenValue) + ) { + return buildCrossusageDevMockProbeOutput(ctx) + } + + if (!accessToken && !refreshTokenValue) { + ctx.host.log.error("probe failed: no access or refresh token in sqlite/keychain") + throw "Not logged in. " + LOGIN_HINT + } + + ctx.host.log.info("tokens loaded from " + authSource + ": accessToken=" + (accessToken ? "yes" : "no") + " refreshToken=" + (refreshTokenValue ? "yes" : "no")) + + const nowMs = Date.now() + + // Proactively refresh if token is expired or about to expire + if (needsRefresh(ctx, accessToken, nowMs)) { + ctx.host.log.info("token needs refresh (expired or expiring soon)") + let refreshed = null + try { + refreshed = refreshToken(ctx, refreshTokenValue, authSource) + } catch (e) { + // If refresh fails but we have an access token, try it anyway + ctx.host.log.warn("refresh failed but have access token, will try: " + String(e)) + if (!accessToken) throw e + } + if (refreshed) { + accessToken = refreshed + } else if (!accessToken) { + ctx.host.log.error("refresh failed and no access token available") + throw "Not logged in. " + LOGIN_HINT + } + } + + let usageResp + let didRefresh = false + try { + usageResp = ctx.util.retryOnceOnAuth({ + request: (token) => { + try { + return connectPost(ctx, USAGE_URL, token || accessToken) + } catch (e) { + ctx.host.log.error("usage request exception: " + String(e)) + if (didRefresh) { + throw "Usage request failed after refresh. Try again." + } + throw "Usage request failed. Check your connection." + } + }, + refresh: () => { + ctx.host.log.info("usage returned 401, attempting refresh") + didRefresh = true + const refreshed = refreshToken(ctx, refreshTokenValue, authSource) + if (refreshed) accessToken = refreshed + return refreshed + }, + }) + } catch (e) { + if (typeof e === "string") throw e + ctx.host.log.error("usage request failed: " + String(e)) + throw "Usage request failed. Check your connection." + } + + if (ctx.util.isAuthStatus(usageResp.status)) { + ctx.host.log.error("usage returned auth error after all retries: status=" + usageResp.status) + throw "Token expired. " + LOGIN_HINT + } + + if (usageResp.status < 200 || usageResp.status >= 300) { + const bodySnippet = + typeof usageResp.bodyText === "string" ? usageResp.bodyText.slice(0, 240) : "" + ctx.host.log.error( + "usage returned error: status=" + usageResp.status + " body=" + bodySnippet + ) + + var connectDetail = null + // Connect/gRPC-Web sometimes returns 400/403 while other endpoints or cursor.com still work. + if (usageResp.status === 400 || usageResp.status === 403) { + connectDetail = extractConnectUsageErrorDetail(ctx, usageResp.bodyText) + ctx.host.log.warn( + "GetCurrentPeriodUsage returned " + + String(usageResp.status) + + "; detail=" + + String(connectDetail) + + "; trying GetUsageLimitStatusAndActiveGrants, then REST, then Stripe summary" + ) + + try { + var lgResp = connectPost(ctx, USAGE_LIMIT_GRANTS_URL, accessToken) + if (lgResp.status >= 200 && lgResp.status < 300) { + var lgRaw = ctx.util.tryParseJson(lgResp.bodyText) + var usageShape = normalizeLimitGrantsToUsageShape(lgRaw) + if (usageShape) { + var planNameLg = "" + try { + var planRespLg = connectPost(ctx, PLAN_URL, accessToken) + if (planRespLg.status >= 200 && planRespLg.status < 300) { + var planLg = ctx.util.tryParseJson(planRespLg.bodyText) + if (planLg && planLg.planInfo && planLg.planInfo.planName) { + planNameLg = planLg.planInfo.planName + } + } + } catch (ePlan) { + ctx.host.log.warn("plan info during limit-grants fallback failed: " + String(ePlan)) + } + var creditGrantsLg = null + try { + var creditsRespLg = connectPost(ctx, CREDITS_URL, accessToken) + if (creditsRespLg.status >= 200 && creditsRespLg.status < 300) { + creditGrantsLg = ctx.util.tryParseJson(creditsRespLg.bodyText) + } + } catch (eCr) { + ctx.host.log.warn("credit grants during limit-grants fallback failed: " + String(eCr)) + } + var stripeBalanceLg = fetchStripeBalance(ctx, accessToken) || 0 + try { + var builtLg = buildPlanAndLinesFromConnectStyleUsage(ctx, usageShape, planNameLg, { + creditGrants: creditGrantsLg, + stripeBalanceCents: stripeBalanceLg, + }) + if (builtLg) return builtLg + } catch (eBuild) { + ctx.host.log.warn("limit-grants connect-style build failed: " + String(eBuild)) + } + } + } else { + ctx.host.log.warn( + "GetUsageLimitStatusAndActiveGrants returned status=" + lgResp.status + ) + } + } catch (eLg) { + ctx.host.log.warn("GetUsageLimitStatusAndActiveGrants request failed: " + String(eLg)) + } + + try { + return buildUnknownRequestBasedResult(ctx, accessToken, "") + } catch (e) { + ctx.host.log.warn("REST usage fallback after Connect error failed: " + String(e)) + } + + try { + var partialStripe = buildPartialStripeSubscriptionResult(ctx, accessToken, connectDetail) + if (partialStripe) return partialStripe + } catch (eP) { + ctx.host.log.warn("partial stripe fallback failed: " + String(eP)) + } + + throw buildConnectFallbackFailedMessage(connectDetail) + } + + throw "Usage request failed (HTTP " + String(usageResp.status) + "). Try again later." + } + + ctx.host.log.info("usage fetch succeeded") + + const usage = ctx.util.tryParseJson(usageResp.bodyText) + if (usage === null) { + throw "Usage response invalid. Try again later." + } + + // Fetch plan info early (needed for request-based fallback detection) + let planName = "" + let planInfoUnavailable = false + try { + const planResp = connectPost(ctx, PLAN_URL, accessToken) + if (planResp.status >= 200 && planResp.status < 300) { + const plan = ctx.util.tryParseJson(planResp.bodyText) + if (plan && plan.planInfo && plan.planInfo.planName) { + planName = plan.planInfo.planName + } + } else { + planInfoUnavailable = true + ctx.host.log.warn("plan info returned error: status=" + planResp.status) + } + } catch (e) { + planInfoUnavailable = true + ctx.host.log.warn("plan info fetch failed: " + String(e)) + } + + const normalizedPlanName = typeof planName === "string" + ? planName.toLowerCase() + : "" + + const hasPlanUsage = !!usage.planUsage + const hasPlanUsageLimit = hasPlanUsage && + typeof usage.planUsage.limit === "number" && + Number.isFinite(usage.planUsage.limit) + const planUsageLimitMissing = hasPlanUsage && !hasPlanUsageLimit + const hasTotalUsagePercent = hasPlanUsage && + typeof usage.planUsage.totalPercentUsed === "number" && + Number.isFinite(usage.planUsage.totalPercentUsed) + + // Enterprise and some Team request-based accounts can return no planUsage + // or a planUsage object without limit from the Connect API. + const needsRequestBasedFallback = usage.enabled !== false && (!hasPlanUsage || planUsageLimitMissing) && ( + normalizedPlanName === "enterprise" || + normalizedPlanName === "team" + ) + if (needsRequestBasedFallback) { + if (normalizedPlanName === "enterprise") { + ctx.host.log.info("detected enterprise account, using REST usage API") + return buildEnterpriseResult(ctx, accessToken, planName) + } + ctx.host.log.info("detected team request-based account, using REST usage API") + return buildTeamRequestBasedResult(ctx, accessToken, planName) + } + + const needsFallbackWithoutPlanInfo = usage.enabled !== false && + (!hasPlanUsage || planUsageLimitMissing) && + !hasTotalUsagePercent && + !normalizedPlanName && + planInfoUnavailable + if (needsFallbackWithoutPlanInfo) { + ctx.host.log.info("plan info unavailable with missing planUsage, attempting REST usage API fallback") + return buildUnknownRequestBasedResult(ctx, accessToken, planName) + } + + if (usage.enabled !== false && planUsageLimitMissing && !hasTotalUsagePercent) { + ctx.host.log.warn("planUsage.limit missing, attempting REST usage API fallback") + try { + return buildUnknownRequestBasedResult(ctx, accessToken, planName) + } catch (e) { + ctx.host.log.warn("REST usage fallback unavailable: " + String(e)) + } + } + + // Team plans may omit `enabled` even with valid plan usage data. + if (usage.enabled === false || !usage.planUsage) { + throw "No active Cursor subscription." + } + + let creditGrants = null + try { + const creditsResp = connectPost(ctx, CREDITS_URL, accessToken) + if (creditsResp.status >= 200 && creditsResp.status < 300) { + creditGrants = ctx.util.tryParseJson(creditsResp.bodyText) + } + } catch (e) { + ctx.host.log.warn("credit grants fetch failed: " + String(e)) + } + + const stripeBalanceCents = fetchStripeBalance(ctx, accessToken) || 0 + + const connectResult = buildPlanAndLinesFromConnectStyleUsage(ctx, usage, planName, { + creditGrants: creditGrants, + stripeBalanceCents: stripeBalanceCents, + }) + if (connectResult === null) { + ctx.host.log.warn("team-inferred account missing planUsage.limit, attempting REST usage API fallback") + return buildUnknownRequestBasedResult(ctx, accessToken, planName) + } + return connectResult + } + + function probe(ctx) { + var result = probeImpl(ctx) + result = attachCursorTranscriptActivity(ctx, result) + result = attachCursorBillingDaily(ctx, result) + return attachCursorMtdUsage(ctx, result) + } + + var pluginId = globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ || "cursor" + globalThis.__openusage_plugin = { id: pluginId, probe } +})() diff --git a/plugins/cursor-nightly/plugin.json b/plugins/cursor-nightly/plugin.json new file mode 100644 index 00000000..f123b90c --- /dev/null +++ b/plugins/cursor-nightly/plugin.json @@ -0,0 +1,21 @@ +{ + "schemaVersion": 1, + "id": "cursor-nightly", + "name": "Cursor Nightly", + "version": "0.0.1", + "entry": "plugin.js", + "icon": "icon.png", + "brandColor": "#E85D3A", + "links": [ + { "label": "Status", "url": "https://status.cursor.com/" }, + { "label": "Dashboard", "url": "https://www.cursor.com/dashboard" } + ], + "lines": [ + { "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 2 }, + { "type": "progress", "label": "Total usage", "scope": "overview", "primaryOrder": 1 }, + { "type": "progress", "label": "Requests", "scope": "overview", "primaryOrder": 3 }, + { "type": "progress", "label": "Auto usage", "scope": "detail" }, + { "type": "progress", "label": "API usage", "scope": "detail" }, + { "type": "progress", "label": "On-demand", "scope": "detail" } + ] +} diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 9ddd63c6..fa451980 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -100,15 +100,38 @@ } const CURSOR_STATE_DB_REL = "Cursor/User/globalStorage/state.vscdb" + const CURSOR_NIGHTLY_STATE_DB_REL = "Cursor Nightly/User/globalStorage/state.vscdb" const CURSOR_STATE_DB_FALLBACK = "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + function cursorBaseProviderId(ctx) { + if (ctx.account && ctx.account.baseProviderId) { + return String(ctx.account.baseProviderId) + } + return globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ || "cursor" + } + function getCursorDbPath(ctx) { + try { + if ( + ctx.host.cursorPaths && + typeof ctx.host.cursorPaths.resolveStateDb === "function" + ) { + var resolved = ctx.host.cursorPaths.resolveStateDb() + if (resolved) return resolved + } + } catch (e) { + ctx.host.log.warn("cursorPaths.resolveStateDb failed: " + String(e)) + } if ( ctx.host.fs && typeof ctx.host.fs.firstExistingAppSupport === "function" ) { - const found = ctx.host.fs.firstExistingAppSupport(CURSOR_STATE_DB_REL) + var rel = + cursorBaseProviderId(ctx) === "cursor-nightly" + ? CURSOR_NIGHTLY_STATE_DB_REL + : CURSOR_STATE_DB_REL + var found = ctx.host.fs.firstExistingAppSupport(rel) if (found) return found } return CURSOR_STATE_DB_FALLBACK @@ -925,15 +948,31 @@ ) } - function cursorSince31DaysAgo() { - var d = new Date() - d.setDate(d.getDate() - 31) + function cursorYyyymmdd(d) { var y = d.getFullYear() var m = String(d.getMonth() + 1).padStart(2, "0") var day = String(d.getDate()).padStart(2, "0") return String(y) + m + day } + function cursorSince31DaysAgo() { + var d = new Date() + d.setDate(d.getDate() - 31) + return cursorYyyymmdd(d) + } + + function cursorUntilToday() { + return cursorYyyymmdd(new Date()) + } + + function cursorAccountDisplayName(ctx) { + var base = cursorBaseProviderId(ctx) + var name = base === "cursor-nightly" ? "Cursor Nightly" : "Cursor" + var label = ctx.account && ctx.account.label ? String(ctx.account.label).trim() : "" + if (label) return name + " (" + label + ")" + return name + } + function dayKeyFromCursorDate(rawDate) { if (!rawDate) return null var s = String(rawDate).trim() @@ -1021,16 +1060,84 @@ } catch (e) { /* ignore */ } } + function formatCompactTokens(n) { + var num = Number(n) || 0 + if (num >= 1000000) return (num / 1000000).toFixed(1) + "M tokens" + if (num >= 1000) return (num / 1000).toFixed(1) + "K tokens" + return String(num) + " tokens" + } + + function attachCursorMtdUsage(ctx, result) { + if (!result || !Array.isArray(result.lines)) return result + if (!ctx.host.cursorUsageExport || typeof ctx.host.cursorUsageExport.queryMtd !== "function") return result + try { + var resp = ctx.host.cursorUsageExport.queryMtd({}) + if (!resp || resp.status !== "ok" || !resp.data) return result + var d = resp.data + var inputTok = Number(d.inputTokens) || 0 + var outputTok = Number(d.outputTokens) || 0 + var total = d.totalTokens != null ? d.totalTokens : inputTok + outputTok + var value = formatCompactTokens(total) + if (inputTok > 0 || outputTok > 0) { + value = formatCompactTokens(inputTok) + " in · " + formatCompactTokens(outputTok) + " out" + if (d.costUsd != null && Number(d.costUsd) > 0) value += " · $" + Number(d.costUsd).toFixed(2) + } else if (d.costUsd != null && Number(d.costUsd) > 0) { + value += " · $" + Number(d.costUsd).toFixed(2) + } + result.lines.push(ctx.line.text({ + label: "MTD usage", + value: value, + subtitle: "From Cursor dashboard export (billing data)", + })) + } catch (e) { /* ignore */ } + return result + } + + function persistCursorBillingDaily(ctx, displayName) { + if (!ctx.host.cursorUsageExport || typeof ctx.host.cursorUsageExport.queryDaily !== "function") return + if (!ctx.host.usageDaily || typeof ctx.host.usageDaily.ingest !== "function") return + try { + var resp = ctx.host.cursorUsageExport.queryDaily({ + since: cursorSince31DaysAgo(), + until: cursorUntilToday(), + }) + if (!resp || resp.status !== "ok" || !resp.data || !Array.isArray(resp.data.daily)) return + if (!resp.data.daily.length) return + var daily = resp.data.daily.map(function (row) { + return { + date: row.date, + totalTokens: row.totalTokens, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + costUsd: row.costUsd, + } + }) + ctx.host.usageDaily.ingest({ + displayName: displayName, + source: "cursor_billing", + daily: daily, + }) + } catch (e) { + ctx.host.log.warn("cursor billing daily ingest failed: " + String(e)) + } + } + function attachCursorTranscriptActivity(ctx, result) { if (!result || !Array.isArray(result.lines)) return result var daily = queryCursorTranscriptDaily(ctx) if (!daily || !daily.length) return result - var displayName = result.plan || "Cursor" + var displayName = cursorAccountDisplayName(ctx) persistCursorUsageDaily(ctx, daily, displayName) pushCursorActivityChartLine(result.lines, ctx, daily) return result } + function attachCursorBillingDaily(ctx, result) { + if (!result) return result + persistCursorBillingDaily(ctx, cursorAccountDisplayName(ctx)) + return result + } + function probeImpl(ctx) { const authState = loadAuthState(ctx) let accessToken = authState.accessToken @@ -1293,8 +1400,12 @@ } function probe(ctx) { - return attachCursorTranscriptActivity(ctx, probeImpl(ctx)) + var result = probeImpl(ctx) + result = attachCursorTranscriptActivity(ctx, result) + result = attachCursorBillingDaily(ctx, result) + return attachCursorMtdUsage(ctx, result) } - globalThis.__openusage_plugin = { id: "cursor", probe } + var pluginId = globalThis.__OPENUSAGE_PLUGIN_REGISTRATION_ID__ || "cursor" + globalThis.__openusage_plugin = { id: pluginId, probe } })() diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 34f7ce16..867377ba 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -2090,6 +2090,7 @@ describe("cursor plugin", () => { expect(ctx.host.usageDaily.ingest).toHaveBeenCalledWith( expect.objectContaining({ source: "cursor_transcripts", + displayName: "Cursor", daily: [{ date: "2026-05-20", totalTokens: 4000, estimated: true }], }) ) @@ -2098,4 +2099,32 @@ describe("cursor plugin", () => { expect(chart.points).toHaveLength(1) expect(chart.points[0].value).toBe(4000) }) + + it("attaches MTD usage line from cursorUsageExport", async () => { + const ctx = makeCtx() + const accessToken = makeJwt({ exp: 9999999999 }) + ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: accessToken }])) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify({ + enabled: true, + planUsage: { totalPercentUsed: 42 }, + }), + }) + ctx.host.cursorUsageExport.queryMtd = vi.fn(() => ({ + status: "ok", + data: { totalTokens: 1_200_000, inputTokens: 800_000, outputTokens: 400_000, costUsd: 4.8 }, + })) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(ctx.host.cursorUsageExport.queryMtd).toHaveBeenCalled() + const mtd = result.lines.find((line) => line.type === "text" && line.label === "MTD usage") + expect(mtd).toBeTruthy() + expect(mtd.value).toContain("800.0K tokens in") + expect(mtd.value).toContain("400.0K tokens out") + expect(mtd.value).toContain("$4.80") + expect(mtd.subtitle).toContain("dashboard export") + }) }) diff --git a/plugins/devin/plugin.js b/plugins/devin/plugin.js index 9d85bff5..45646e6c 100644 --- a/plugins/devin/plugin.js +++ b/plugins/devin/plugin.js @@ -12,11 +12,19 @@ // Devin Desktop FAQ: Devin app data under Devin/; legacy Windsurf/ still read. var APP_STATE_VARIANTS = [ { + source: "Devin app", appSupportRel: "Devin/User/globalStorage/state.vscdb", stateDbFallback: "~/Library/Application Support/Devin/User/globalStorage/state.vscdb", }, { + source: "Devin - Next app", + appSupportRel: "Devin - Next/User/globalStorage/state.vscdb", + stateDbFallback: + "~/Library/Application Support/Devin - Next/User/globalStorage/state.vscdb", + }, + { + source: "Devin app (legacy Windsurf)", appSupportRel: "Windsurf/User/globalStorage/state.vscdb", stateDbFallback: "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb", @@ -137,19 +145,16 @@ return null } - function resolveStateDb(ctx) { - for (var i = 0; i < APP_STATE_VARIANTS.length; i++) { - var variant = APP_STATE_VARIANTS[i] - if ( - ctx.host.fs && - typeof ctx.host.fs.firstExistingAppSupport === "function" - ) { - var found = ctx.host.fs.firstExistingAppSupport(variant.appSupportRel) - if (found) return found - } - if (ctx.host.fs.exists(variant.stateDbFallback)) return variant.stateDbFallback + function resolveStateDbForVariant(ctx, variant) { + if ( + ctx.host.fs && + typeof ctx.host.fs.firstExistingAppSupport === "function" + ) { + var found = ctx.host.fs.firstExistingAppSupport(variant.appSupportRel) + if (found) return found } - return APP_STATE_VARIANTS[0].stateDbFallback + if (ctx.host.fs.exists(variant.stateDbFallback)) return variant.stateDbFallback + return null } function loadCredentialsFile(ctx) { @@ -173,10 +178,12 @@ } } - function loadAppAuth(ctx) { + function readAppAuth(ctx, variant) { + var stateDb = resolveStateDbForVariant(ctx, variant) + if (!stateDb) return null try { var rows = ctx.host.sqlite.query( - resolveStateDb(ctx), + stateDb, "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) @@ -186,10 +193,10 @@ return { apiKey: auth.apiKey, apiServerUrl: null, - source: "Devin app", + source: variant.source, } } catch (e) { - ctx.host.log.warn("failed to read Devin app auth: " + String(e)) + ctx.host.log.warn("failed to read " + variant.source + " auth: " + String(e)) return null } } @@ -320,26 +327,38 @@ } } + function authFingerprint(auth) { + return auth.apiKey + "\n" + effectiveApiServerUrl(auth) + } + + function alreadyAttempted(attempts, auth) { + var fingerprint = authFingerprint(auth) + for (var i = 0; i < attempts.length; i++) { + if (attempts[i] === fingerprint) return true + } + return false + } + function probe(ctx) { var sawApiKey = false var sawAuthFailure = false - var credentials = loadCredentialsFile(ctx) + var attempts = [] + var credentials = loadCredentialsFile(ctx) if (credentials) { sawApiKey = true + attempts.push(authFingerprint(credentials)) var credentialsAttempt = tryAuth(ctx, credentials) if (credentialsAttempt.output) return credentialsAttempt.output if (credentialsAttempt.authFailure) sawAuthFailure = true } - var appAuth = loadAppAuth(ctx) - if ( - appAuth && - (!credentials || - appAuth.apiKey !== credentials.apiKey || - effectiveApiServerUrl(appAuth) !== effectiveApiServerUrl(credentials)) - ) { + for (var i = 0; i < APP_STATE_VARIANTS.length; i++) { + var appAuth = readAppAuth(ctx, APP_STATE_VARIANTS[i]) + if (!appAuth) continue + if (alreadyAttempted(attempts, appAuth)) continue sawApiKey = true + attempts.push(authFingerprint(appAuth)) var appAttempt = tryAuth(ctx, appAuth) if (appAttempt.output) return appAttempt.output if (appAttempt.authFailure) sawAuthFailure = true diff --git a/plugins/devin/plugin.test.js b/plugins/devin/plugin.test.js index 73cb3ee7..ef4f315d 100644 --- a/plugins/devin/plugin.test.js +++ b/plugins/devin/plugin.test.js @@ -5,6 +5,7 @@ const CREDENTIALS_PATH = "~/.local/share/devin/credentials.toml" const WINDOWS_CREDENTIALS_PATH = "~/AppData/Local/devin/credentials.toml" const LINUX_STATE_DB = "~/.config/Devin/User/globalStorage/state.vscdb" const MAC_STATE_DB = "~/Library/Application Support/Devin/User/globalStorage/state.vscdb" +const NEXT_STATE_DB = "~/Library/Application Support/Devin - Next/User/globalStorage/state.vscdb" const DEFAULT_API_SERVER_URL = "https://server.codeium.com" const CLOUD_COMPAT_VERSION = "1.108.2" @@ -58,10 +59,10 @@ function makeQuotaResponse(overrides = {}) { } function mockAppAuth(ctx, apiKey = "devin-session-token$app", stateDb = MAC_STATE_DB) { + ctx.host.fs.writeText(stateDb, "db") ctx.host.sqlite.query.mockImplementation((db, sql) => { - expect(db).toBe(stateDb) expect(String(sql)).toContain("windsurfAuthStatus") - return makeAuthStatus(apiKey) + return db === stateDb ? makeAuthStatus(apiKey) : "[]" }) } @@ -188,6 +189,64 @@ describe("devin plugin", () => { expect(sentBody.metadata.apiKey).toBe("devin-session-token$app") }) + it("reads auth from the Devin - Next app when stable Devin is absent", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(NEXT_STATE_DB, "db") + ctx.host.sqlite.query.mockImplementation((db, sql) => { + expect(String(sql)).toContain("windsurfAuthStatus") + if (db === NEXT_STATE_DB) return makeAuthStatus("devin-session-token$next") + return "[]" + }) + ctx.host.http.request.mockReturnValue({ + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Pro" } })), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Pro") + const queriedDbs = ctx.host.sqlite.query.mock.calls.map(([db]) => db) + expect(queriedDbs).toEqual([NEXT_STATE_DB]) + const sentBody = JSON.parse(String(ctx.host.http.request.mock.calls[0][0].bodyText)) + expect(sentBody.metadata.apiKey).toBe("devin-session-token$next") + expect(ctx.host.log.info).toHaveBeenCalledWith( + expect.stringContaining("Devin quota diagnostics source=Devin - Next app") + ) + }) + + it("falls back from a stale stable-Devin token to the Devin - Next app", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(MAC_STATE_DB, "db") + ctx.host.fs.writeText(NEXT_STATE_DB, "db") + ctx.host.sqlite.query.mockImplementation((db, sql) => { + expect(String(sql)).toContain("windsurfAuthStatus") + if (db === MAC_STATE_DB) return makeAuthStatus("devin-session-token$stable") + if (db === NEXT_STATE_DB) return makeAuthStatus("devin-session-token$next") + return "[]" + }) + ctx.host.http.request.mockImplementation((request) => { + const body = JSON.parse(String(request.bodyText)) + if (body.metadata.apiKey === "devin-session-token$stable") { + return { status: 401, bodyText: "{}" } + } + return { + status: 200, + bodyText: JSON.stringify(makeQuotaResponse({ planInfo: { planName: "Teams" } })), + } + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + expect(result.plan).toBe("Teams") + expect(ctx.host.http.request).toHaveBeenCalledTimes(2) + const triedKeys = ctx.host.http.request.mock.calls.map( + ([request]) => JSON.parse(String(request.bodyText)).metadata.apiKey + ) + expect(triedKeys).toEqual(["devin-session-token$stable", "devin-session-token$next"]) + }) + it("ignores plaintext API server URLs from CLI credentials", async () => { const ctx = makeCtx() ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml({ diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index 6003c2a4..14bf13bb 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -112,6 +112,14 @@ export const makeCtx = () => { cursorLogs: { queryDaily: vi.fn(() => ({ status: "no_data", data: { daily: [] } })), }, + cursorPaths: { + resolveStateDb: vi.fn(() => null), + }, + cursorUsageExport: { + queryMtd: vi.fn(() => ({ status: "no_data" })), + queryStats: vi.fn(() => ({ status: "no_data" })), + queryDaily: vi.fn(() => ({ status: "no_data", data: { daily: [] } })), + }, fireworks: { exportBillingMetrics: vi.fn(() => ({ status: "unavailable" })), }, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6892fbc0..195087b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "crossusage" -version = "1.0.11" +version = "1.1.0" description = "CrossUsage — cross-platform fork of OpenUsage, an open source AI subscription limit tracker" authors = ["barramee kottanawadee "] homepage = "https://github.com/barramee27/crossusage" @@ -41,6 +41,8 @@ tauri-plugin-autostart = "2.5.1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } tauri-plugin-liquid-glass = "0.1.6" tauri-plugin-positioner = { version = "2.3.1", features = ["tray-icon"] } +tauri-plugin-dialog = "2" +tauri-plugin-fs = "2" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" @@ -59,4 +61,4 @@ objc2-app-kit = { version = "0.3", features = ["NSClipView", "NSColor", "NSEvent objc2-web-kit = { version = "0.3", features = ["WKPreferences", "WKWebView", "WKWebViewConfiguration"] } [dev-dependencies] -serial_test = "3.4" +serial_test = "3.5" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 067ed08a..ab773d1c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -23,6 +23,11 @@ "liquid-glass:default", "core:menu:default", "positioner:default", + "dialog:default", + "dialog:allow-open", + "fs:default", + "fs:allow-write-text-file", + "fs:allow-home-write-recursive", "core:window:allow-hide", "core:window:allow-show", "core:window:allow-set-focus" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6dec689c..1fad2bbc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -865,6 +865,28 @@ fn list_usage_daily( .map_err(|e| e.to_string()) } +#[tauri::command] +fn query_cursor_usage_stats( + plugin_id: Option, + since: Option, + until: Option, + group: Option, +) -> Result { + let plugin_id = plugin_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("cursor"); + let payload = crossusage_core::cursor_usage_export::query_usage_stats( + plugin_id, + since.as_deref(), + until.as_deref(), + group.as_deref().unwrap_or("model"), + ) + .map_err(|e| e.to_string())?; + serde_json::to_value(payload).map_err(|e| e.to_string()) +} + #[tauri::command] fn set_tray_restart_label(text: String) { tray::set_tray_restart_menu_text(&text); @@ -993,7 +1015,9 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_positioner::init()); + .plugin(tauri_plugin_positioner::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()); #[cfg(target_os = "macos")] let builder = builder.plugin(tauri_nspanel::init()); @@ -1031,6 +1055,7 @@ pub fn run() { get_support_bundle_json, list_usage_history, list_usage_daily, + query_cursor_usage_stats, clear_usage_history, get_platform, usage_alert_sound::play_usage_alert_sound, diff --git a/src-tauri/src/local_http_api/server.rs b/src-tauri/src/local_http_api/server.rs index 05da872e..898c7aae 100644 --- a/src-tauri/src/local_http_api/server.rs +++ b/src-tauri/src/local_http_api/server.rs @@ -125,12 +125,12 @@ fn handle_connection(mut stream: TcpStream, _permit: ConnectionPermit) { path }; - let response = route(method, path); + let response = route(method, path, raw_path); let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); } -fn route(method: &str, path: &str) -> String { +fn route(method: &str, path: &str, raw_path: &str) -> String { if path == "/v1/usage" { return match method { "GET" => handle_get_usage_collection(), @@ -149,9 +149,82 @@ fn route(method: &str, path: &str) -> String { } } + if path == "/v1/history/quota" { + return match method { + "GET" => handle_get_history_quota(raw_path), + "OPTIONS" => response_no_content(), + _ => response_method_not_allowed(), + }; + } + + if path == "/v1/history/daily" { + return match method { + "GET" => handle_get_history_daily(raw_path), + "OPTIONS" => response_no_content(), + _ => response_method_not_allowed(), + }; + } + response_not_found("not_found") } +fn parse_limit_query(raw_path: &str, default: u32) -> u32 { + let query = raw_path.split('?').nth(1).unwrap_or(""); + for part in query.split('&') { + let mut kv = part.splitn(2, '='); + if kv.next() == Some("limit") { + if let Some(v) = kv.next() { + if let Ok(n) = v.parse::() { + return n.clamp(1, 2000); + } + } + } + } + default +} + +fn handle_get_history_quota(raw_path: &str) -> String { + let limit = parse_limit_query(raw_path, 80); + let dir = { + let state = cache_state().lock().expect("cache state poisoned"); + state.app_data_dir.clone() + }; + if !crossusage_core::usage_history::persist_usage_history_enabled(&dir) { + return response_json(200, "OK", "[]"); + } + match crossusage_core::usage_history::list_recent(&dir, limit) { + Ok(rows) => { + let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| "[]".to_string()); + response_json(200, "OK", &body) + } + Err(e) => { + let body = format!(r#"{{"error":"{}"}}"#, e); + response_json(500, "Internal Server Error", &body) + } + } +} + +fn handle_get_history_daily(raw_path: &str) -> String { + let limit = parse_limit_query(raw_path, 120); + let dir = { + let state = cache_state().lock().expect("cache state poisoned"); + state.app_data_dir.clone() + }; + if !crossusage_core::usage_history::persist_usage_history_enabled(&dir) { + return response_json(200, "OK", "[]"); + } + match crossusage_core::usage_daily::list_recent(&dir, limit, None) { + Ok(rows) => { + let body = serde_json::to_string_pretty(&rows).unwrap_or_else(|_| "[]".to_string()); + response_json(200, "OK", &body) + } + Err(e) => { + let body = format!(r#"{{"error":"{}"}}"#, e); + response_json(500, "Internal Server Error", &body) + } + } +} + fn handle_get_usage_collection() -> String { let snapshots = { let state = cache_state().lock().expect("cache state poisoned"); @@ -236,27 +309,39 @@ mod tests { } } + #[test] + fn route_get_history_quota_returns_200() { + let resp = route("GET", "/v1/history/quota", "/v1/history/quota"); + assert!(resp.starts_with("HTTP/1.1 200")); + } + + #[test] + fn route_get_history_daily_returns_200() { + let resp = route("GET", "/v1/history/daily", "/v1/history/daily?limit=50"); + assert!(resp.starts_with("HTTP/1.1 200")); + } + #[test] fn route_get_usage_returns_200() { - let resp = route("GET", "/v1/usage"); + let resp = route("GET", "/v1/usage", "/v1/usage"); assert!(resp.starts_with("HTTP/1.1 200")); } #[test] fn route_unknown_path_returns_404() { - let resp = route("GET", "/v2/something"); + let resp = route("GET", "/v2/something", "/v2/something"); assert!(resp.starts_with("HTTP/1.1 404")); } #[test] fn route_post_returns_405() { - let resp = route("POST", "/v1/usage"); + let resp = route("POST", "/v1/usage", "/v1/usage"); assert!(resp.starts_with("HTTP/1.1 405")); } #[test] fn route_options_returns_204_with_cors() { - let resp = route("OPTIONS", "/v1/usage"); + let resp = route("OPTIONS", "/v1/usage", "/v1/usage"); assert!(resp.starts_with("HTTP/1.1 204")); assert!(resp.contains("Access-Control-Allow-Origin: *")); } @@ -270,7 +355,7 @@ mod tests { state.snapshots.clear(); } - let resp = route("GET", "/v1/usage/nonexistent"); + let resp = route("GET", "/v1/usage/nonexistent", "/v1/usage/nonexistent"); assert!(resp.starts_with("HTTP/1.1 404")); assert!(resp.contains("provider_not_found")); } @@ -284,7 +369,7 @@ mod tests { state.snapshots.clear(); } - let resp = route("GET", "/v1/usage/claude"); + let resp = route("GET", "/v1/usage/claude", "/v1/usage/claude"); assert!(resp.starts_with("HTTP/1.1 204")); } @@ -299,14 +384,14 @@ mod tests { .insert("claude".to_string(), make_snapshot("claude", "Claude")); } - let resp = route("GET", "/v1/usage/claude"); + let resp = route("GET", "/v1/usage/claude", "/v1/usage/claude"); assert!(resp.starts_with("HTTP/1.1 200")); assert!(resp.contains("fetchedAt")); } #[test] fn route_options_on_provider_returns_204() { - let resp = route("OPTIONS", "/v1/usage/claude"); + let resp = route("OPTIONS", "/v1/usage/claude", "/v1/usage/claude"); assert!(resp.starts_with("HTTP/1.1 204")); assert!(resp.contains("Access-Control-Allow-Methods: GET, OPTIONS")); } diff --git a/src-tauri/src/panel.rs b/src-tauri/src/panel.rs index 8a16d1c8..dbe4d2bb 100644 --- a/src-tauri/src/panel.rs +++ b/src-tauri/src/panel.rs @@ -272,7 +272,10 @@ pub fn position_panel_at_tray_icon( let icon_center_x = icon_logical_x + (icon_logical_w / 2.0); let panel_x = icon_center_x - (panel_width / 2.0); let nudge_up: f64 = 6.0; - let panel_y = icon_logical_y + icon_logical_h - nudge_up; + // Clamp to the monitor's top edge: when the menu bar is set to auto-hide, + // the tray rect sits above the visible screen, which would otherwise push + // the panel's top edge off-screen and clip it. + let panel_y = (icon_logical_y + icon_logical_h - nudge_up).max(mon_logical_y); set_panel_top_left_immediately(&window, app_handle, panel_x, panel_y, primary_logical_h); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index af8b20e7..3e0d5367 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "crossusage", "mainBinaryName": "crossusage", - "version": "1.0.11", + "version": "1.1.0", "identifier": "com.barramee27.crossusage", "build": { "beforeDevCommand": "node scripts/tauri-before-dev.cjs", @@ -42,7 +42,13 @@ "license": "MIT", "linux": { "deb": { - "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1 | libappindicator3-1"] + "depends": [ + "libwebkit2gtk-4.1-0", + "libayatana-appindicator3-1 | libappindicator3-1", + "libgtk-3-0", + "xdg-desktop-portal", + "xdg-desktop-portal-gtk | xdg-desktop-portal-gnome | xdg-desktop-portal-cosmic" + ] } }, "macOS": { diff --git a/src/App.tsx b/src/App.tsx index e8e3bf06..85864841 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -86,6 +86,9 @@ function App() { setUsageAlertThreshold, setCustomUsageAlertThreshold, setUsageAlertSound, + setUsagePaceAlertEnabled, + setUsageSpikeAlertEnabled, + setUsageSpikeAlertThresholdPct, setShowTrayIcon, onboardingComplete, setOnboardingComplete, @@ -113,6 +116,9 @@ function App() { setUsageAlertThreshold: state.setUsageAlertThreshold, setCustomUsageAlertThreshold: state.setCustomUsageAlertThreshold, setUsageAlertSound: state.setUsageAlertSound, + setUsagePaceAlertEnabled: state.setUsagePaceAlertEnabled, + setUsageSpikeAlertEnabled: state.setUsageSpikeAlertEnabled, + setUsageSpikeAlertThresholdPct: state.setUsageSpikeAlertThresholdPct, setShowTrayIcon: state.setShowTrayIcon, onboardingComplete: state.onboardingComplete, setOnboardingComplete: state.setOnboardingComplete, @@ -181,6 +187,9 @@ function App() { setUsageAlertThreshold, setCustomUsageAlertThreshold, setUsageAlertSound, + setUsagePaceAlertEnabled, + setUsageSpikeAlertEnabled, + setUsageSpikeAlertThresholdPct, setOnboardingComplete, setLoadingForPlugins, setErrorForPlugins, @@ -204,6 +213,9 @@ function App() { handleUsageAlertThresholdChange, handleUsageAlertCustomThresholdChange, handleUsageAlertSoundChange, + handleUsagePaceAlertEnabledChange, + handleUsageSpikeAlertEnabledChange, + handleUsageSpikeAlertThresholdPctChange, } = useSettingsDisplayActions({ setThemeMode, setDisplayMode, @@ -218,6 +230,9 @@ function App() { setUsageAlertThreshold, setCustomUsageAlertThreshold, setUsageAlertSound, + setUsagePaceAlertEnabled, + setUsageSpikeAlertEnabled, + setUsageSpikeAlertThresholdPct, scheduleTrayIconUpdate, }) @@ -578,6 +593,9 @@ function App() { onUsageAlertThresholdChange: handleUsageAlertThresholdChange, onUsageAlertCustomThresholdChange: handleUsageAlertCustomThresholdChange, onUsageAlertSoundChange: handleUsageAlertSoundChange, + onUsagePaceAlertEnabledChange: handleUsagePaceAlertEnabledChange, + onUsageSpikeAlertEnabledChange: handleUsageSpikeAlertEnabledChange, + onUsageSpikeAlertThresholdPctChange: handleUsageSpikeAlertThresholdPctChange, onUIScaleChange: handleUIScaleChange, onShowAccountIdentityChange: handleShowAccountIdentityChange, onSetCursorTrayMetricForAllAccounts: handleSetCursorTrayMetricForAllAccounts, diff --git a/src/components/app/app-content.test.tsx b/src/components/app/app-content.test.tsx index 03347074..10d56306 100644 --- a/src/components/app/app-content.test.tsx +++ b/src/components/app/app-content.test.tsx @@ -76,7 +76,19 @@ function createProps(): AppContentProps { }, onGlobalShortcutChange: vi.fn(), onStartOnLoginChange: vi.fn(), + onTimeFormatModeChange: vi.fn(), + onTrayLineToggle: vi.fn(), + onUsageAlertEnabledChange: vi.fn(), + onUsageAlertThresholdChange: vi.fn(), + onUsageAlertCustomThresholdChange: vi.fn(), + onUsageAlertSoundChange: vi.fn(), + onUsagePaceAlertEnabledChange: vi.fn(), + onUsageSpikeAlertEnabledChange: vi.fn(), + onUsageSpikeAlertThresholdPctChange: vi.fn(), + onUIScaleChange: vi.fn(), onShowAccountIdentityChange: vi.fn(), + onSetCursorTrayMetricForAllAccounts: vi.fn(), + cursorRequestsLineAvailable: null, } } diff --git a/src/components/app/app-content.tsx b/src/components/app/app-content.tsx index 8f773096..1ade3f7b 100644 --- a/src/components/app/app-content.tsx +++ b/src/components/app/app-content.tsx @@ -5,6 +5,7 @@ import { SettingsPage, type ProviderAccountCredentialInput } from "@/pages/setti import type { DisplayPluginState } from "@/hooks/app/use-app-plugin-views" import type { SettingsPluginState } from "@/hooks/app/use-settings-plugin-list" import type { TraySettingsPreview } from "@/hooks/app/use-tray-icon" +import { useAppPluginStore } from "@/stores/app-plugin-store" import { useAppPreferencesStore } from "@/stores/app-preferences-store" import { useAppUiStore } from "@/stores/app-ui-store" import type { @@ -50,6 +51,9 @@ export type AppContentActionProps = { onUsageAlertThresholdChange: (value: UsageAlertThreshold) => void onUsageAlertCustomThresholdChange: (value: number | null) => void onUsageAlertSoundChange: (value: UsageAlertSound) => void + onUsagePaceAlertEnabledChange: (value: boolean) => void + onUsageSpikeAlertEnabledChange: (value: boolean) => void + onUsageSpikeAlertThresholdPctChange: (value: import("@/lib/settings").UsageSpikeAlertThresholdPct) => void onUIScaleChange: (value: UIScale) => void onShowAccountIdentityChange: (value: boolean) => void onSetCursorTrayMetricForAllAccounts: (lineLabel: string) => void @@ -85,6 +89,9 @@ export function AppContent({ onUsageAlertThresholdChange, onUsageAlertCustomThresholdChange, onUsageAlertSoundChange, + onUsagePaceAlertEnabledChange, + onUsageSpikeAlertEnabledChange, + onUsageSpikeAlertThresholdPctChange, onUIScaleChange, onShowAccountIdentityChange, onSetCursorTrayMetricForAllAccounts, @@ -96,6 +103,8 @@ export function AppContent({ })) ) + const pluginSettings = useAppPluginStore((state) => state.pluginSettings) + const { displayMode, resetTimerDisplayMode, @@ -110,6 +119,9 @@ export function AppContent({ usageAlertThreshold, customUsageAlertThreshold, usageAlertSound, + usagePaceAlertEnabled, + usageSpikeAlertEnabled, + usageSpikeAlertThresholdPct, uiScale, showAccountIdentity, } = useAppPreferencesStore( @@ -127,6 +139,9 @@ export function AppContent({ usageAlertThreshold: state.usageAlertThreshold, customUsageAlertThreshold: state.customUsageAlertThreshold, usageAlertSound: state.usageAlertSound, + usagePaceAlertEnabled: state.usagePaceAlertEnabled, + usageSpikeAlertEnabled: state.usageSpikeAlertEnabled, + usageSpikeAlertThresholdPct: state.usageSpikeAlertThresholdPct, uiScale: state.uiScale, showAccountIdentity: state.showAccountIdentity, })) @@ -136,6 +151,8 @@ export function AppContent({ return ( = 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} + +type CursorUsageStatsPanelProps = { + className?: string + pluginId?: string +} + +export function CursorUsageStatsPanel({ + className, + pluginId = "cursor", +}: CursorUsageStatsPanelProps) { + const [preset, setPreset] = useState("mtd") + const [group, setGroup] = useState("model") + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + const load = useCallback(async () => { + if (!isTauri()) return + const range = presetRange(preset) + setLoading(true) + setError(null) + try { + const result = await invoke("query_cursor_usage_stats", { + pluginId, + since: range.since, + until: range.until, + group, + }) + setData(result) + } catch (e) { + console.error("query_cursor_usage_stats:", e) + setData(null) + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [pluginId, preset, group]) + + useEffect(() => { + void load() + }, [load]) + + if (!isTauri()) return null + + const labelCol = group === "model" ? "Model" : "Provider" + + return ( +
+
+

Billing usage (Cursor export)

+
+ {(["mtd", "7d", "30d"] as const).map((p) => ( + + ))} +
+
+

+ Same data as crossusage-cli usage-stats — dashboard CSV export (billing). +

+
+ + + +
+ {loading ?

Loading…

: null} + {error ?

{error}

: null} + {data && data.rows.length > 0 ? ( +
+ + + + + + + + + + + + + + {data.rows.map((row) => ( + + + + + + + + + + ))} + + + + + + + + + + +
{labelCol}InputOutputCache WCache RTotalCost
{row.key}{fmtNum(row.input)}{fmtNum(row.output)}{fmtNum(row.cacheWrite)}{fmtNum(row.cacheHit)}{fmtNum(row.totalTokens)}${row.costUsd.toFixed(2)}
Total{fmtNum(data.totals.input)}{fmtNum(data.totals.output)}{fmtNum(data.totals.cacheWrite)}{fmtNum(data.totals.cacheHit)}{fmtNum(data.totals.totalTokens)}${data.totals.costUsd.toFixed(2)}
+
+ ) : null} + {data && data.rows.length === 0 && !loading && !error ? ( +

No usage in this range.

+ ) : null} +
+ ) +} diff --git a/src/components/provider-icon.tsx b/src/components/provider-icon.tsx new file mode 100644 index 00000000..409c6bc9 --- /dev/null +++ b/src/components/provider-icon.tsx @@ -0,0 +1,88 @@ +import { cn } from "@/lib/utils" +import { getRelativeLuminance } from "@/lib/color" +import { isRasterProviderIconUrl } from "@/lib/provider-icon-url" + +function getMaskIconColor(brandColor: string | undefined, isDark: boolean): string { + if (!brandColor) return "currentColor" + const luminance = getRelativeLuminance(brandColor) + if (isDark && luminance < 0.15) return "#ffffff" + if (!isDark && luminance > 0.85) return "currentColor" + return brandColor +} + +type ProviderIconProps = { + iconUrl?: string + brandColor?: string + isDark?: boolean + sizePx: number + className?: string + /** When set, mask icons use primary-foreground vs foreground (settings previews). */ + isActive?: boolean + alt?: string +} + +export function ProviderIcon({ + iconUrl, + brandColor, + isDark = false, + sizePx, + className, + isActive, + alt = "", +}: ProviderIconProps) { + const sizeStyle = { width: `${sizePx}px`, height: `${sizePx}px` } + + if (!iconUrl) { + const textClass = isActive ? "text-primary-foreground" : "text-foreground" + return ( + + + + ) + } + + if (isRasterProviderIconUrl(iconUrl)) { + return ( + {alt} + ) + } + + const colorClass = isActive != null + ? isActive + ? "bg-primary-foreground" + : "bg-foreground" + : undefined + + return ( + + ) +} diff --git a/src/components/side-nav.tsx b/src/components/side-nav.tsx index 62f73970..0a728834 100644 --- a/src/components/side-nav.tsx +++ b/src/components/side-nav.tsx @@ -21,8 +21,8 @@ import { import { CSS } from "@dnd-kit/utilities" import { cn } from "@/lib/utils" -import { getRelativeLuminance } from "@/lib/color" import { useDarkMode } from "@/hooks/use-dark-mode" +import { ProviderIcon } from "@/components/provider-icon" type ActiveView = "home" | "settings" | string @@ -72,14 +72,6 @@ function NavButton({ isActive, onClick, onContextMenu, children, "aria-label": a ) } -function getIconColor(brandColor: string | undefined, isDark: boolean): string { - if (!brandColor) return "currentColor" - const luminance = getRelativeLuminance(brandColor) - if (isDark && luminance < 0.15) return "#ffffff" - if (!isDark && luminance > 0.85) return "currentColor" - return brandColor -} - interface SortableNavPluginProps { plugin: NavPlugin isActive: boolean @@ -112,21 +104,12 @@ function SortableNavPlugin({ plugin, isActive, isDark, onClick, onContextMenu }: onContextMenu={onContextMenu} aria-label={plugin.name} > - diff --git a/src/components/usage-insights-banner.tsx b/src/components/usage-insights-banner.tsx new file mode 100644 index 00000000..a7a9b8f5 --- /dev/null +++ b/src/components/usage-insights-banner.tsx @@ -0,0 +1,159 @@ +import { useMemo, useState } from "react" +import { UsageSparkline } from "@/components/usage-sparkline" +import { dismissInsight, filterDismissedInsights } from "@/lib/insight-dismiss" +import type { UsageInsight } from "@/lib/usage-insights" +import { + buildAggregatedSparklinePoints, + countDistinctDaysInWindow, +} from "@/lib/usage-daily-sparkline" +import type { UsageDailyRow } from "@/lib/usage-daily" +import { + formatRollupSummary, + rollingWindowDays, + type WeeklyRollupResult, +} from "@/lib/weekly-rollup" +import { cn } from "@/lib/utils" + +type UsageInsightsBannerProps = { + insights: UsageInsight[] + rollup: WeeklyRollupResult | null + rollup30: WeeklyRollupResult | null + dailyRows?: UsageDailyRow[] + persistEnabled: boolean + nowMs: number + onSelectProvider?: (instanceId: string) => void + className?: string +} + +function insightIcon(kind: UsageInsight["kind"]): string { + if (kind === "pace") return "⚠" + if (kind === "tight") return "◎" + return "↻" +} + +export function UsageInsightsBanner({ + insights, + rollup, + rollup30, + dailyRows = [], + persistEnabled, + nowMs, + onSelectProvider, + className, +}: UsageInsightsBannerProps) { + const [, bump] = useState(0) + + const visibleInsights = useMemo( + () => filterDismissedInsights(insights, nowMs), + [insights, nowMs, bump], + ) + + const sparklineWindow = rollingWindowDays(7, new Date(nowMs)).current + const sparklinePoints = useMemo( + () => buildAggregatedSparklinePoints(dailyRows, sparklineWindow), + [dailyRows, sparklineWindow.startDay, sparklineWindow.endDay], + ) + const showSparkline = + persistEnabled && sparklinePoints.length >= 2 + + const showRollup30 = + rollup30 != null && + countDistinctDaysInWindow(dailyRows, rollup30.currentWindow) >= 7 + + const showRollupHint = !persistEnabled + const hasInsights = visibleInsights.length > 0 + const hasRollup = rollup != null + + if (!hasInsights && !hasRollup && !showRollup30 && !showSparkline && !showRollupHint) { + return null + } + + const handleDismiss = (row: UsageInsight, e: React.MouseEvent) => { + e.stopPropagation() + const until = row.dismissUntilMs ?? nowMs + 86_400_000 + dismissInsight(row, until) + bump((n) => n + 1) + } + + return ( +
+ {hasInsights ? ( +
    + {visibleInsights.map((row) => ( +
  • + + +
  • + ))} +
+ ) : null} + + {hasRollup ? ( +

+ + ∑ + + {formatRollupSummary(rollup)} + {rollup.topContributors.length > 0 ? ( + + {" "} + — {rollup.topContributors.map((c) => c.displayName).join(", ")} + + ) : null} +

+ ) : null} + + {showRollup30 ? ( +

+ + ∑ + + {formatRollupSummary(rollup30!)} +

+ ) : null} + + {showSparkline ? ( + + ) : null} + + {(hasRollup || showRollup30) && !showSparkline ? ( + + Estimated from local logs (not billing). + + ) : null} + + {showRollupHint ? ( +

Enable usage history for weekly totals.

+ ) : null} +
+ ) +} diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 6ca86502..e063b262 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -75,6 +75,9 @@ vi.mock("@/lib/settings", () => ({ DEFAULT_USAGE_ALERT_ENABLED: false, DEFAULT_USAGE_ALERT_SOUND: "Basso", DEFAULT_USAGE_ALERT_THRESHOLD: 20, + DEFAULT_USAGE_PACE_ALERT_ENABLED: true, + DEFAULT_USAGE_SPIKE_ALERT_ENABLED: false, + DEFAULT_USAGE_SPIKE_ALERT_THRESHOLD_PCT: 50, DEFAULT_MENUBAR_ICON_STYLE: "provider", DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT: false, DEFAULT_RESET_TIMER_DISPLAY_MODE: "relative", @@ -93,6 +96,9 @@ vi.mock("@/lib/settings", () => ({ loadUsageAlertEnabled: vi.fn().mockResolvedValue(false), loadUsageAlertSound: vi.fn().mockResolvedValue("Basso"), loadUsageAlertThreshold: vi.fn().mockResolvedValue(20), + loadUsagePaceAlertEnabled: vi.fn().mockResolvedValue(true), + loadUsageSpikeAlertEnabled: vi.fn().mockResolvedValue(false), + loadUsageSpikeAlertThresholdPct: vi.fn().mockResolvedValue(50), loadMenubarIconStyle: loadMenubarIconStyleMock, loadPreferMenubarWeeklyLimit: loadPreferMenubarWeeklyLimitMock, loadPluginSettings: loadPluginSettingsMock, @@ -131,6 +137,9 @@ function createArgs() { setUsageAlertThreshold: vi.fn(), setCustomUsageAlertThreshold: vi.fn(), setUsageAlertSound: vi.fn(), + setUsagePaceAlertEnabled: vi.fn(), + setUsageSpikeAlertEnabled: vi.fn(), + setUsageSpikeAlertThresholdPct: vi.fn(), setOnboardingComplete: vi.fn(), setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index a4f2237d..18a38caf 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -15,6 +15,9 @@ import { DEFAULT_USAGE_ALERT_CUSTOM_THRESHOLD, DEFAULT_USAGE_ALERT_ENABLED, DEFAULT_USAGE_ALERT_SOUND, + DEFAULT_USAGE_PACE_ALERT_ENABLED, + DEFAULT_USAGE_SPIKE_ALERT_ENABLED, + DEFAULT_USAGE_SPIKE_ALERT_THRESHOLD_PCT, DEFAULT_USAGE_ALERT_THRESHOLD, DEFAULT_MENUBAR_ICON_STYLE, DEFAULT_PREFER_MENUBAR_WEEKLY_LIMIT, @@ -32,6 +35,9 @@ import { loadUsageAlertCustomThreshold, loadUsageAlertEnabled, loadUsageAlertSound, + loadUsagePaceAlertEnabled, + loadUsageSpikeAlertEnabled, + loadUsageSpikeAlertThresholdPct, loadUsageAlertThreshold, loadMenubarIconStyle, migrateLegacyTraySettings, @@ -80,6 +86,9 @@ type UseSettingsBootstrapArgs = { setUsageAlertThreshold: (value: UsageAlertThreshold) => void setCustomUsageAlertThreshold: (value: number | null) => void setUsageAlertSound: (value: UsageAlertSound) => void + setUsagePaceAlertEnabled: (value: boolean) => void + setUsageSpikeAlertEnabled: (value: boolean) => void + setUsageSpikeAlertThresholdPct: (value: import("@/lib/settings").UsageSpikeAlertThresholdPct) => void setOnboardingComplete: (value: boolean) => void setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void @@ -105,6 +114,9 @@ export function useSettingsBootstrap({ setUsageAlertThreshold, setCustomUsageAlertThreshold, setUsageAlertSound, + setUsagePaceAlertEnabled, + setUsageSpikeAlertEnabled, + setUsageSpikeAlertThresholdPct, setOnboardingComplete, setLoadingForPlugins, setErrorForPlugins, @@ -277,6 +289,27 @@ export function useSettingsBootstrap({ console.error("Failed to load usage alert sound:", error) } + let storedUsagePaceAlertEnabled = DEFAULT_USAGE_PACE_ALERT_ENABLED + try { + storedUsagePaceAlertEnabled = await loadUsagePaceAlertEnabled() + } catch (error) { + console.error("Failed to load usage pace alert enabled:", error) + } + + let storedUsageSpikeAlertEnabled = DEFAULT_USAGE_SPIKE_ALERT_ENABLED + try { + storedUsageSpikeAlertEnabled = await loadUsageSpikeAlertEnabled() + } catch (error) { + console.error("Failed to load usage spike alert enabled:", error) + } + + let storedUsageSpikeAlertThresholdPct = DEFAULT_USAGE_SPIKE_ALERT_THRESHOLD_PCT + try { + storedUsageSpikeAlertThresholdPct = await loadUsageSpikeAlertThresholdPct() + } catch (error) { + console.error("Failed to load usage spike alert threshold:", error) + } + if (isMounted) { setPluginSettings(normalized) setAutoUpdateInterval(storedInterval) @@ -295,6 +328,9 @@ export function useSettingsBootstrap({ setUsageAlertThreshold(storedUsageAlertThreshold) setCustomUsageAlertThreshold(storedUsageAlertCustomThreshold) setUsageAlertSound(storedUsageAlertSound) + setUsagePaceAlertEnabled(storedUsagePaceAlertEnabled) + setUsageSpikeAlertEnabled(storedUsageSpikeAlertEnabled) + setUsageSpikeAlertThresholdPct(storedUsageSpikeAlertThresholdPct) setOnboardingComplete(onboardingDone) const enabledIds = getEnabledPluginIds(normalized) diff --git a/src/hooks/app/use-settings-display-actions.ts b/src/hooks/app/use-settings-display-actions.ts index b45ea8b6..6f88f6f2 100644 --- a/src/hooks/app/use-settings-display-actions.ts +++ b/src/hooks/app/use-settings-display-actions.ts @@ -12,6 +12,9 @@ import { saveUsageAlertCustomThreshold, saveUsageAlertEnabled, saveUsageAlertSound, + saveUsagePaceAlertEnabled, + saveUsageSpikeAlertEnabled, + saveUsageSpikeAlertThresholdPct, saveUsageAlertThreshold, type DisplayMode, type MenubarIconStyle, @@ -21,6 +24,7 @@ import { type UIScale, type UsageAlertSound, type UsageAlertThreshold, + type UsageSpikeAlertThresholdPct, } from "@/lib/settings" type ScheduleTrayIconUpdate = (reason: "probe" | "settings" | "init", delayMs?: number) => void @@ -39,6 +43,9 @@ type UseSettingsDisplayActionsArgs = { setUsageAlertThreshold: (value: UsageAlertThreshold) => void setCustomUsageAlertThreshold: (value: number | null) => void setUsageAlertSound: (value: UsageAlertSound) => void + setUsagePaceAlertEnabled: (value: boolean) => void + setUsageSpikeAlertEnabled: (value: boolean) => void + setUsageSpikeAlertThresholdPct: (value: UsageSpikeAlertThresholdPct) => void scheduleTrayIconUpdate: ScheduleTrayIconUpdate } @@ -56,6 +63,9 @@ export function useSettingsDisplayActions({ setUsageAlertThreshold, setCustomUsageAlertThreshold, setUsageAlertSound, + setUsagePaceAlertEnabled, + setUsageSpikeAlertEnabled, + setUsageSpikeAlertThresholdPct, scheduleTrayIconUpdate, }: UseSettingsDisplayActionsArgs) { const handleThemeModeChange = useCallback((mode: ThemeMode) => { @@ -153,6 +163,30 @@ export function useSettingsDisplayActions({ }) }, [setUsageAlertSound]) + const handleUsagePaceAlertEnabledChange = useCallback((value: boolean) => { + track("setting_changed", { setting: "usage_pace_alert_enabled", value: value ? "true" : "false" }) + setUsagePaceAlertEnabled(value) + void saveUsagePaceAlertEnabled(value).catch((error) => { + console.error("Failed to save usage pace alert enabled:", error) + }) + }, [setUsagePaceAlertEnabled]) + + const handleUsageSpikeAlertEnabledChange = useCallback((value: boolean) => { + track("setting_changed", { setting: "usage_spike_alert_enabled", value: value ? "true" : "false" }) + setUsageSpikeAlertEnabled(value) + void saveUsageSpikeAlertEnabled(value).catch((error) => { + console.error("Failed to save usage spike alert enabled:", error) + }) + }, [setUsageSpikeAlertEnabled]) + + const handleUsageSpikeAlertThresholdPctChange = useCallback((value: UsageSpikeAlertThresholdPct) => { + track("setting_changed", { setting: "usage_spike_alert_threshold_pct", value: String(value) }) + setUsageSpikeAlertThresholdPct(value) + void saveUsageSpikeAlertThresholdPct(value).catch((error) => { + console.error("Failed to save usage spike alert threshold:", error) + }) + }, [setUsageSpikeAlertThresholdPct]) + return { handleThemeModeChange, handleDisplayModeChange, @@ -167,5 +201,8 @@ export function useSettingsDisplayActions({ handleUsageAlertThresholdChange, handleUsageAlertCustomThresholdChange, handleUsageAlertSoundChange, + handleUsagePaceAlertEnabledChange, + handleUsageSpikeAlertEnabledChange, + handleUsageSpikeAlertThresholdPctChange, } } diff --git a/src/hooks/app/use-usage-alert.test.ts b/src/hooks/app/use-usage-alert.test.ts new file mode 100644 index 00000000..b10f49ca --- /dev/null +++ b/src/hooks/app/use-usage-alert.test.ts @@ -0,0 +1,109 @@ +import { renderHook } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { PluginOutput } from "@/lib/plugin-types" +import { useAppPluginStore } from "@/stores/app-plugin-store" +import { useAppPreferencesStore } from "@/stores/app-preferences-store" + +const { sendNotificationAsync } = vi.hoisted(() => ({ + sendNotificationAsync: vi.fn(() => Promise.resolve()), +})) + +vi.mock("@/lib/notification", () => ({ + sendNotificationAsync, +})) + +vi.mock("@tauri-apps/api/core", () => ({ + convertFileSrc: (path: string) => path, +})) + +import { useUsageAlert } from "@/hooks/app/use-usage-alert" + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000 + +function makeOutput(overrides: Partial = {}): PluginOutput { + const resetsAt = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString() + return { + providerId: "cursor", + displayName: "Cursor", + lines: [ + { + type: "progress", + label: "Total usage", + used: 80, + limit: 100, + format: { kind: "percent" }, + resetsAt, + periodDurationMs: WEEK_MS, + }, + ], + iconUrl: "icon", + ...overrides, + } +} + +describe("useUsageAlert", () => { + beforeEach(() => { + sendNotificationAsync.mockClear() + useAppPreferencesStore.getState().resetState() + useAppPluginStore.getState().resetState() + + useAppPreferencesStore.getState().setUsageAlertEnabled(true) + useAppPreferencesStore.getState().setUsageAlertThreshold(20) + useAppPreferencesStore.getState().setUsagePaceAlertEnabled(true) + + useAppPluginStore.getState().setPluginsMeta([ + { + id: "cursor", + name: "Cursor", + iconUrl: "icon", + iconFilePath: "/icon.png", + brandColor: "#000", + lines: [{ type: "progress", label: "Total usage", scope: "overview" }], + primaryCandidates: ["Total usage"], + }, + ]) + useAppPluginStore.getState().setPluginSettings({ + order: ["cursor"], + disabled: [], + }) + }) + + it("sends low-remaining alert on primary progress line", () => { + const { result } = renderHook(() => useUsageAlert()) + result.current.checkUsageAlert(makeOutput()) + + expect(sendNotificationAsync).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("Less than 20% remaining on Cursor (Total usage)"), + }), + ) + }) + + it("sends pace-behind alert when projected to exceed limit before reset", () => { + const { result } = renderHook(() => useUsageAlert()) + result.current.checkUsageAlert(makeOutput()) + + expect(sendNotificationAsync).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining("projected to run out before reset"), + }), + ) + }) + + it("skips pace alerts when usagePaceAlertEnabled is false", () => { + useAppPreferencesStore.getState().setUsagePaceAlertEnabled(false) + const { result } = renderHook(() => useUsageAlert()) + result.current.checkUsageAlert(makeOutput()) + + expect(sendNotificationAsync).toHaveBeenCalledTimes(1) + expect(sendNotificationAsync.mock.calls[0][0].body).not.toContain("projected to run out") + }) + + it("does nothing when alerts are disabled", () => { + useAppPreferencesStore.getState().setUsageAlertEnabled(false) + const { result } = renderHook(() => useUsageAlert()) + result.current.checkUsageAlert(makeOutput()) + + expect(sendNotificationAsync).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/app/use-usage-alert.ts b/src/hooks/app/use-usage-alert.ts index 996548be..a0a301fc 100644 --- a/src/hooks/app/use-usage-alert.ts +++ b/src/hooks/app/use-usage-alert.ts @@ -1,7 +1,9 @@ import { useCallback, useRef } from "react" import { convertFileSrc } from "@tauri-apps/api/core" import type { PluginOutput } from "@/lib/plugin-types" +import { calculatePaceStatus } from "@/lib/pace-status" import { sendNotificationAsync } from "@/lib/notification" +import { resolvePrimaryProgressLine } from "@/lib/primary-progress-line" import { useAppPluginStore } from "@/stores/app-plugin-store" import { useAppPreferencesStore } from "@/stores/app-preferences-store" @@ -11,65 +13,120 @@ export function useUsageAlert() { usageAlertThreshold, customUsageAlertThreshold, usageAlertSound, + usagePaceAlertEnabled, + preferMenubarWeeklyLimit, } = useAppPreferencesStore() - const { pluginsMeta } = useAppPluginStore() + const { pluginsMeta, pluginSettings } = useAppPluginStore() - const notifiedMapRef = useRef>({}) + const lowRemainingNotifiedRef = useRef>({}) + const paceNotifiedRef = useRef>({}) - const checkUsageAlert = useCallback( - (output: PluginOutput) => { - const sessionLine = output.lines.find( - (line): line is Extract<(typeof output.lines)[number], { type: "progress" }> => - line.type === "progress" && line.label === "Session" - ) - if (!sessionLine) return - if (!Number.isFinite(sessionLine.used) || !Number.isFinite(sessionLine.limit)) return - if (sessionLine.limit <= 0) return - - const usedPercent = (sessionLine.used / sessionLine.limit) * 100 - const remaining = 100 - usedPercent - - const effectiveThreshold = - usageAlertThreshold === "custom" ? customUsageAlertThreshold : usageAlertThreshold - if (effectiveThreshold == null) return - - if (remaining > effectiveThreshold) { - notifiedMapRef.current[output.providerId] = false - return - } - - if (!usageAlertEnabled) return - if (notifiedMapRef.current[output.providerId] === true) return - - const meta = pluginsMeta.find((plugin) => plugin.id === output.providerId) + const sendAlert = useCallback( + (providerId: string, _displayName: string, body: string) => { + const meta = pluginsMeta.find((plugin) => plugin.id === providerId) const iconFilePath = meta?.iconFilePath void sendNotificationAsync({ title: "Usage Alert", - body: `Less than ${effectiveThreshold}% remaining on ${output.displayName}`, + body, sound: usageAlertSound, ...(iconFilePath ? { attachments: [{ id: "icon", url: convertFileSrc(iconFilePath) }] } : {}), + }).catch((error) => { + console.error("Failed to send usage alert notification:", error) + }) + }, + [pluginsMeta, usageAlertSound], + ) + + const checkUsageAlert = useCallback( + (output: PluginOutput) => { + if (!usageAlertEnabled) return + if (!pluginSettings) return + + const instanceId = output.providerId + const meta = pluginsMeta.find((p) => p.id === instanceId) + if (!meta) return + + const primary = resolvePrimaryProgressLine({ + meta, + data: output, + pluginSettings, + instanceId, + preferWeeklyLimit: preferMenubarWeeklyLimit, }) - .then(() => { - notifiedMapRef.current[output.providerId] = true - }) - .catch((error) => { - notifiedMapRef.current[output.providerId] = true - console.error("Failed to send usage alert notification:", error) - }) + + if (!primary) return + if (!Number.isFinite(primary.used) || !Number.isFinite(primary.limit)) return + if (primary.limit <= 0) return + + const displayName = output.displayName + const lineLabel = primary.label + + if (primary.format?.kind === "percent") { + const usedPercent = (primary.used / primary.limit) * 100 + const remaining = 100 - usedPercent + const effectiveThreshold = + usageAlertThreshold === "custom" ? customUsageAlertThreshold : usageAlertThreshold + + if (effectiveThreshold != null) { + if (remaining > effectiveThreshold) { + lowRemainingNotifiedRef.current[instanceId] = false + } else if (!lowRemainingNotifiedRef.current[instanceId]) { + lowRemainingNotifiedRef.current[instanceId] = true + sendAlert( + instanceId, + displayName, + `Less than ${effectiveThreshold}% remaining on ${displayName} (${lineLabel})`, + ) + } + } + + if (usagePaceAlertEnabled) { + const resetsAtMs = primary.resetsAt ? Date.parse(primary.resetsAt) : NaN + const periodDurationMs = primary.periodDurationMs + const paceKey = `${instanceId}:${resetsAtMs}:pace` + + if ( + Number.isFinite(resetsAtMs) && + periodDurationMs != null && + periodDurationMs > 0 + ) { + const nowMs = Date.now() + const pace = calculatePaceStatus( + primary.used, + primary.limit, + resetsAtMs, + periodDurationMs, + nowMs, + ) + if (pace?.status !== "behind") { + paceNotifiedRef.current[paceKey] = false + } else if (!paceNotifiedRef.current[paceKey]) { + paceNotifiedRef.current[paceKey] = true + sendAlert( + instanceId, + displayName, + `${displayName} (${lineLabel}) — projected to run out before reset`, + ) + } + } + } + } }, [ customUsageAlertThreshold, + pluginSettings, pluginsMeta, + preferMenubarWeeklyLimit, + sendAlert, usageAlertEnabled, - usageAlertSound, usageAlertThreshold, - ] + usagePaceAlertEnabled, + ], ) return { checkUsageAlert } } - diff --git a/src/hooks/use-persist-usage-history.ts b/src/hooks/use-persist-usage-history.ts new file mode 100644 index 00000000..3590cfb3 --- /dev/null +++ b/src/hooks/use-persist-usage-history.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react" +import { loadPersistUsageHistory } from "@/lib/settings" + +export function usePersistUsageHistory() { + const [enabled, setEnabled] = useState(false) + + useEffect(() => { + let mounted = true + void loadPersistUsageHistory() + .then((v) => { + if (mounted) setEnabled(v) + }) + .catch((e) => console.error("loadPersistUsageHistory:", e)) + return () => { + mounted = false + } + }, []) + + return enabled +} diff --git a/src/hooks/use-spend-spike-alert.ts b/src/hooks/use-spend-spike-alert.ts new file mode 100644 index 00000000..057e9140 --- /dev/null +++ b/src/hooks/use-spend-spike-alert.ts @@ -0,0 +1,37 @@ +import { useCallback, useRef } from "react" +import { sendNotificationAsync } from "@/lib/notification" +import type { WeeklyRollupResult } from "@/lib/weekly-rollup" +import { useAppPreferencesStore } from "@/stores/app-preferences-store" + +export function useSpendSpikeAlert() { + const { usageSpikeAlertEnabled, usageSpikeAlertThresholdPct, usageAlertSound } = + useAppPreferencesStore() + const notifiedRef = useRef>({}) + + const checkSpendSpike = useCallback( + (rollup: WeeklyRollupResult | null) => { + if (!usageSpikeAlertEnabled || !rollup) return + if (rollup.windowDays !== 7) return + + const pct = rollup.costDeltaPct + const cost = rollup.current.costUsd + if (pct == null || pct < usageSpikeAlertThresholdPct) return + if (cost < 1) return + + const dedupKey = `spike:${rollup.priorWindow.endDay}` + if (notifiedRef.current[dedupKey]) return + notifiedRef.current[dedupKey] = true + + void sendNotificationAsync({ + title: "Usage Alert", + body: `Estimated 7-day spend ~$${cost.toFixed(2)} is up ${pct}% vs the prior 7 days (local logs).`, + sound: usageAlertSound, + }).catch((error) => { + console.error("Failed to send spend spike alert:", error) + }) + }, + [usageAlertSound, usageSpikeAlertEnabled, usageSpikeAlertThresholdPct], + ) + + return { checkSpendSpike } +} diff --git a/src/hooks/use-weekly-rollup.ts b/src/hooks/use-weekly-rollup.ts new file mode 100644 index 00000000..fc3f4a55 --- /dev/null +++ b/src/hooks/use-weekly-rollup.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { invoke } from "@tauri-apps/api/core" +import { isTauri } from "@tauri-apps/api/core" +import type { UsageDailyRow } from "@/lib/usage-daily" +import { + computeRollingRollup, + type WeeklyRollupResult, +} from "@/lib/weekly-rollup" + +export function useWeeklyRollup(persistEnabled: boolean) { + const [dailyRows, setDailyRows] = useState([]) + const [rollup, setRollup] = useState(null) + const [rollup30, setRollup30] = useState(null) + const debounceRef = useRef(null) + + const reload = useCallback(async () => { + if (!isTauri() || !persistEnabled) { + setDailyRows([]) + setRollup(null) + setRollup30(null) + return + } + try { + const rows = await invoke("list_usage_daily", { limit: 120 }) + setDailyRows(rows) + setRollup(computeRollingRollup(rows, 7)) + setRollup30(computeRollingRollup(rows, 30)) + } catch (e) { + console.error("list_usage_daily:", e) + setDailyRows([]) + setRollup(null) + setRollup30(null) + } + }, [persistEnabled]) + + const scheduleReload = useCallback(() => { + if (debounceRef.current !== null) clearTimeout(debounceRef.current) + debounceRef.current = window.setTimeout(() => { + debounceRef.current = null + void reload() + }, 32_000) + }, [reload]) + + useEffect(() => { + void reload() + return () => { + if (debounceRef.current !== null) clearTimeout(debounceRef.current) + } + }, [reload]) + + return { dailyRows, rollup, rollup30, reload, scheduleReload } +} diff --git a/src/lib/history-export.test.ts b/src/lib/history-export.test.ts new file mode 100644 index 00000000..17533289 --- /dev/null +++ b/src/lib/history-export.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest" +import { + buildDailyTokensCsv, + buildExportSummary, + buildQuotaHistoryCsv, + formatUsagePercent, +} from "@/lib/history-export" +import type { UsageDailyRow } from "@/lib/usage-daily" + +describe("history-export", () => { + it("includes BOM, header comments, and extra token columns in daily CSV", () => { + const rows: UsageDailyRow[] = [ + { + instanceId: "cursor", + dayKey: "2026-06-06", + displayName: "Cursor", + totalTokens: 338347, + inputTokens: 200000, + outputTokens: 138347, + costUsd: null, + source: "cursor_transcripts", + ingestedAtMs: 1, + }, + ] + const csv = buildDailyTokensCsv(rows) + expect(csv.startsWith("\uFEFF")).toBe(true) + expect(csv).toContain("# CrossUsage daily token export") + expect(csv).toContain("provider,account_key") + expect(csv).toContain("2026-06-06,Cursor,cursor,338347,200000,138347,,cursor_transcripts") + expect(csv).toContain("cursor_transcripts") + }) + + it("summarizes quota history export", () => { + const csv = buildQuotaHistoryCsv([ + { + capturedAtMs: Date.parse("2026-06-06T08:00:00.000Z"), + instanceId: "cursor", + displayName: "Cursor", + primaryPercent: 42, + plan: "Pro", + }, + ]) + expect(csv).toContain("# rows,1") + expect(csv).toContain("usage_percent") + expect(csv).toContain('"42.0"') + expect(csv).toContain(',"Pro"') + }) + + it("rounds long usage percent floats and quotes cells", () => { + expect(formatUsagePercent(20.6866666666667)).toBe("20.7") + const csv = buildQuotaHistoryCsv([ + { + capturedAtMs: Date.parse("2026-06-06T08:00:00.000Z"), + instanceId: "cursor", + displayName: "Cursor", + primaryPercent: 20.6866666666667, + plan: "Pro", + }, + ]) + expect(csv).toContain('"20.7","Pro"') + }) + + it("buildExportSummary explains missing Cursor cost", () => { + const summary = buildExportSummary( + [], + [ + { + instanceId: "cursor", + dayKey: "2026-06-06", + displayName: "Cursor", + totalTokens: 100, + inputTokens: null, + outputTokens: null, + costUsd: null, + source: "cursor_transcripts", + ingestedAtMs: 1, + }, + ], + ) + expect(summary).toContain("cursor_billing") + expect(summary).toContain("Text to Columns") + }) +}) diff --git a/src/lib/history-export.ts b/src/lib/history-export.ts new file mode 100644 index 00000000..a2249a22 --- /dev/null +++ b/src/lib/history-export.ts @@ -0,0 +1,272 @@ +import { isTauri } from "@tauri-apps/api/core" +import type { UsageDailyRow } from "@/lib/usage-daily" + +export type UsageHistoryExportRow = { + capturedAtMs: number + instanceId: string + displayName: string + primaryPercent: number + plan: string | null +} + +const CSV_BOM = "\uFEFF" + +function csvEscape(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"` + } + return value +} + +/** Quote every cell so LibreOffice/Calc does not glue `21.06` + `Pro` into one column. */ +function csvCell(value: string): string { + return `"${value.replace(/"/g, '""')}"` +} + +/** Match Settings table / charts (one decimal, 0–100). */ +export function formatUsagePercent(n: number): string { + if (!Number.isFinite(n)) return "" + return (Math.round(n * 10) / 10).toFixed(1) +} + +function withCsvBom(content: string): string { + return `${CSV_BOM}${content}` +} + +function joinCsvLines(lines: string[]): string { + return withCsvBom(lines.join("\r\n")) +} + +function commentLine(text: string): string { + return `# ${text}` +} + +function sumTokens(rows: UsageDailyRow[]): number { + return rows.reduce((sum, row) => sum + (row.totalTokens ?? 0), 0) +} + +function sumCost(rows: UsageDailyRow[]): number | null { + let total = 0 + let any = false + for (const row of rows) { + if (row.costUsd != null && Number.isFinite(row.costUsd)) { + total += row.costUsd + any = true + } + } + return any ? total : null +} + +function dayRange(rows: UsageDailyRow[]): { from: string | null; to: string | null } { + if (rows.length === 0) return { from: null, to: null } + const days = rows.map((r) => r.dayKey).sort() + return { from: days[0] ?? null, to: days[days.length - 1] ?? null } +} + +function sourceNote(source: string): string { + if (source === "cursor_billing") { + return "Cursor dashboard billing CSV (includes cost USD)" + } + if (source === "cursor_transcripts") { + return "local transcript token estimate only; refresh Cursor to pull billing rows" + } + if (source === "ccusage" || source.includes("ccusage")) { + return "from local Claude/Codex usage logs" + } + return source +} + +export function buildQuotaHistoryCsv(rows: UsageHistoryExportRow[]): string { + const generated = new Date().toISOString() + const header = "captured_at,instance_id,display_name,usage_percent,plan" + const meta = [ + commentLine(`CrossUsage quota snapshot export`), + commentLine(`generated_at,${generated}`), + commentLine(`rows,${rows.length}`), + commentLine(`description,Quota usage percent 0-100 after each successful provider refresh`), + commentLine(`note,usage_percent is rounded to 1 decimal; includes all enabled providers`), + commentLine(`libreoffice_hint,UTF-8 comma-separated; quoted cells avoid 21.06Pro merge bugs`), + "", + ] + const dataLines = rows.map((r) => { + const at = new Date(r.capturedAtMs).toISOString() + const plan = r.plan ?? "" + return [ + csvCell(at), + csvCell(r.instanceId), + csvCell(r.displayName), + csvCell(formatUsagePercent(r.primaryPercent)), + csvCell(plan), + ].join(",") + }) + return joinCsvLines([...meta, header, ...dataLines]) +} + +export function buildDailyTokensCsv(rows: UsageDailyRow[]): string { + const generated = new Date().toISOString() + const { from, to } = dayRange(rows) + const totalTokens = sumTokens(rows) + const totalCost = sumCost(rows) + const header = + "day,provider,account_key,total_tokens,input_tokens,output_tokens,cost_usd,source,notes" + const meta = [ + commentLine(`CrossUsage daily token export`), + commentLine(`generated_at,${generated}`), + commentLine(`rows,${rows.length}`), + commentLine(`date_from,${from ?? ""}`), + commentLine(`date_to,${to ?? ""}`), + commentLine(`total_tokens,${totalTokens}`), + commentLine(`total_cost_usd,${totalCost != null ? totalCost.toFixed(4) : ""}`), + commentLine( + `note,provider column matches Quota over time chart; account_key is internal (cursor, cursor:work)`, + ), + commentLine( + `note,cost_usd comes from cursor_billing rows after a successful Cursor refresh`, + ), + commentLine(`libreoffice_hint,Data → Text to Columns → Separated by → Comma`), + "", + ] + const dataLines = rows.map((r) => { + const notes = r.costUsd == null ? sourceNote(r.source) : "" + return [ + csvEscape(r.dayKey), + csvEscape(r.displayName), + csvEscape(r.instanceId), + r.totalTokens != null ? String(r.totalTokens) : "", + r.inputTokens != null ? String(r.inputTokens) : "", + r.outputTokens != null ? String(r.outputTokens) : "", + r.costUsd != null ? String(r.costUsd) : "", + csvEscape(r.source), + csvEscape(notes), + ].join(",") + }) + + const byInstance = new Map() + for (const row of rows) { + const prev = byInstance.get(row.instanceId) ?? { + label: row.displayName, + tokens: 0, + cost: null as number | null, + } + prev.tokens += row.totalTokens ?? 0 + if (row.costUsd != null && Number.isFinite(row.costUsd)) { + prev.cost = (prev.cost ?? 0) + row.costUsd + } + byInstance.set(row.instanceId, prev) + } + const summary = + byInstance.size > 0 + ? [ + "", + commentLine("--- totals by account ---"), + ...Array.from(byInstance.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([id, v]) => + commentLine( + `${id},${v.label},tokens=${v.tokens},cost_usd=${v.cost != null ? v.cost.toFixed(4) : ""}`, + ), + ), + ] + : [] + + return joinCsvLines([...meta, header, ...dataLines, ...summary]) +} + +export function buildExportSummary( + quotaRows: UsageHistoryExportRow[], + dailyRows: UsageDailyRow[], +): string { + const generated = new Date().toISOString() + const { from, to } = dayRange(dailyRows) + const totalTokens = sumTokens(dailyRows) + const totalCost = sumCost(dailyRows) + const lines = [ + "CrossUsage usage export", + `Generated: ${generated}`, + "", + "Files in this folder:", + " crossusage-daily-tokens-*.csv — per-day token totals", + " crossusage-quota-history-*.csv — quota % snapshots over time", + "", + `Daily token rows: ${dailyRows.length}`, + ] + if (from && to) lines.push(`Daily date range: ${from} to ${to}`) + lines.push(`Total tokens (daily file): ${totalTokens.toLocaleString("en-US")}`) + if (totalCost != null) { + lines.push(`Total cost USD (daily file): $${totalCost.toFixed(2)}`) + } else if (dailyRows.some((r) => r.source === "cursor_transcripts")) { + lines.push( + "Total cost USD: missing — only transcript estimates saved so far.", + "Refresh Cursor in CrossUsage, then export again (billing rows use source=cursor_billing).", + ) + } + lines.push("", `Quota snapshot rows: ${quotaRows.length}`) + lines.push( + "", + "Open CSV in LibreOffice:", + " If all data is in column A, use Data → Text to Columns → Separated by → Comma.", + " UTF-8 BOM is included so most apps split columns automatically.", + ) + return lines.join("\n") +} + +function downloadTextFile(filename: string, content: string) { + const blob = new Blob([content], { type: "text/csv;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +/** Browser fallback: downloads to the browser default folder (usually ~/Downloads). */ +export function downloadUsageHistoryCsv( + quotaRows: UsageHistoryExportRow[], + dailyRows: UsageDailyRow[], +): void { + const stamp = new Date().toISOString().slice(0, 10) + downloadTextFile(`crossusage-daily-tokens-${stamp}.csv`, buildDailyTokensCsv(dailyRows)) + downloadTextFile(`crossusage-quota-history-${stamp}.csv`, buildQuotaHistoryCsv(quotaRows)) +} + +export type UsageHistoryExportResult = { + directory: string + files: string[] +} + +/** Desktop: native folder picker, then writes CSV + summary text into that folder. */ +export async function exportUsageHistoryToFolder( + quotaRows: UsageHistoryExportRow[], + dailyRows: UsageDailyRow[], +): Promise { + if (!isTauri()) { + downloadUsageHistoryCsv(quotaRows, dailyRows) + return null + } + + const { open } = await import("@tauri-apps/plugin-dialog") + const { writeTextFile } = await import("@tauri-apps/plugin-fs") + + const picked = await open({ + directory: true, + multiple: false, + title: "Choose folder for usage export", + }) + if (!picked || Array.isArray(picked)) return null + + const directory = picked.replace(/\/$/, "") + const stamp = new Date().toISOString().slice(0, 10) + const names = [ + `crossusage-daily-tokens-${stamp}.csv`, + `crossusage-quota-history-${stamp}.csv`, + `crossusage-export-summary-${stamp}.txt`, + ] as const + const paths = names.map((name) => `${directory}/${name}`) + + await writeTextFile(paths[0], buildDailyTokensCsv(dailyRows)) + await writeTextFile(paths[1], buildQuotaHistoryCsv(quotaRows)) + await writeTextFile(paths[2], buildExportSummary(quotaRows, dailyRows)) + + return { directory, files: paths } +} diff --git a/src/lib/insight-dismiss.ts b/src/lib/insight-dismiss.ts new file mode 100644 index 00000000..d57fe6f3 --- /dev/null +++ b/src/lib/insight-dismiss.ts @@ -0,0 +1,34 @@ +import type { UsageInsight } from "@/lib/usage-insights" + +const PREFIX = "insightDismiss:" + +export function insightDismissStorageKey(row: UsageInsight): string { + return `${PREFIX}${row.kind}:${row.instanceId}:${row.lineLabel}` +} + +export function isInsightDismissed(row: UsageInsight, nowMs = Date.now()): boolean { + try { + const raw = localStorage.getItem(insightDismissStorageKey(row)) + if (!raw) return false + const until = Number(raw) + if (!Number.isFinite(until)) return true + return nowMs < until + } catch { + return false + } +} + +export function dismissInsight(row: UsageInsight, untilMs: number): void { + try { + localStorage.setItem(insightDismissStorageKey(row), String(untilMs)) + } catch { + /* ignore quota errors */ + } +} + +export function filterDismissedInsights( + insights: UsageInsight[], + nowMs = Date.now(), +): UsageInsight[] { + return insights.filter((row) => !isInsightDismissed(row, nowMs)) +} diff --git a/src/lib/primary-progress-line.test.ts b/src/lib/primary-progress-line.test.ts new file mode 100644 index 00000000..f873e0be --- /dev/null +++ b/src/lib/primary-progress-line.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest" + +import { resolvePrimaryProgressLine } from "@/lib/primary-progress-line" +import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" +import type { PluginSettings } from "@/lib/settings" + +const baseMeta: PluginMeta = { + id: "cursor", + name: "Cursor", + iconUrl: "", + iconFilePath: "", + primaryCandidates: ["Credits", "Total usage", "Requests"], + lines: [ + { type: "progress", label: "Credits", scope: "overview" }, + { type: "progress", label: "Total usage", scope: "overview" }, + { type: "progress", label: "Weekly limit", scope: "overview" }, + ], +} + +const baseData: PluginOutput = { + providerId: "cursor", + displayName: "Cursor", + iconUrl: "", + lines: [ + { + type: "progress", + label: "Credits", + used: 10, + limit: 100, + format: { kind: "dollars" }, + }, + { + type: "progress", + label: "Total usage", + used: 50, + limit: 100, + format: { kind: "percent" }, + }, + { + type: "progress", + label: "Weekly limit", + used: 20, + limit: 100, + format: { kind: "percent" }, + }, + ], +} + +const settings: PluginSettings = { order: ["cursor"], disabled: [] } + +describe("resolvePrimaryProgressLine", () => { + it("picks first available primaryCandidates", () => { + const line = resolvePrimaryProgressLine({ + meta: baseMeta, + data: baseData, + pluginSettings: settings, + instanceId: "cursor", + }) + expect(line?.label).toBe("Credits") + }) + + it("prefers weekly overview line when preferWeeklyLimit", () => { + const line = resolvePrimaryProgressLine({ + meta: baseMeta, + data: baseData, + pluginSettings: settings, + instanceId: "cursor", + preferWeeklyLimit: true, + }) + expect(line?.label).toBe("Weekly limit") + }) + + it("uses first configured tray line when trayLines set", () => { + const line = resolvePrimaryProgressLine({ + meta: baseMeta, + data: baseData, + pluginSettings: { ...settings, trayLines: { cursor: ["Total usage"] } }, + instanceId: "cursor", + }) + expect(line?.label).toBe("Total usage") + }) + + it("returns null when trayLines is __NONE__", () => { + const line = resolvePrimaryProgressLine({ + meta: baseMeta, + data: baseData, + pluginSettings: { ...settings, trayLines: { cursor: ["__NONE__"] } }, + instanceId: "cursor", + }) + expect(line).toBeNull() + }) +}) diff --git a/src/lib/primary-progress-line.ts b/src/lib/primary-progress-line.ts new file mode 100644 index 00000000..aac335c1 --- /dev/null +++ b/src/lib/primary-progress-line.ts @@ -0,0 +1,101 @@ +import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" +import type { PluginSettings } from "@/lib/settings" + +export type ProgressLine = Extract< + PluginOutput["lines"][number], + { type: "progress"; label: string; used: number; limit: number } +> + +export function isProgressLine(line: PluginOutput["lines"][number]): line is ProgressLine { + return line.type === "progress" +} + +function isWeeklyOverviewLine(meta: PluginMeta, label: string): boolean { + return meta.lines.some( + (line) => + line.type === "progress" && + line.scope === "overview" && + line.label === label && + /weekly/i.test(label), + ) +} + +/** Tray line labels configured for an instance, or undefined when using primary auto-pick. */ +export function resolveConfiguredTrayLineLabels( + pluginSettings: PluginSettings, + instanceId: string, +): string[] | undefined { + const configured = pluginSettings.trayLines?.[instanceId] + if (configured === undefined) return undefined + if (configured[0] === "__NONE__") return [] + return configured +} + +/** Primary overview progress line for insights, alerts, and default tray bar. */ +export function resolvePrimaryProgressLine(args: { + meta: PluginMeta + data: PluginOutput + pluginSettings: PluginSettings + instanceId: string + preferWeeklyLimit?: boolean +}): ProgressLine | null { + const { meta, data, pluginSettings, instanceId, preferWeeklyLimit = false } = args + + const configuredLabels = resolveConfiguredTrayLineLabels(pluginSettings, instanceId) + if (configuredLabels !== undefined) { + const first = configuredLabels[0] + if (!first) return null + return ( + data.lines.find((l): l is ProgressLine => isProgressLine(l) && l.label === first) ?? null + ) + } + + const weeklyLabel = preferWeeklyLimit + ? data.lines + .filter(isProgressLine) + .find((line) => isWeeklyOverviewLine(meta, line.label))?.label + : undefined + + const primaryLabel = + weeklyLabel ?? + (meta.primaryCandidates ?? []).find((label) => + data.lines.some((line) => isProgressLine(line) && line.label === label), + ) + + if (!primaryLabel) return null + return ( + data.lines.find( + (line): line is ProgressLine => isProgressLine(line) && line.label === primaryLabel, + ) ?? null + ) +} + +/** All configured or primary tray progress lines for one instance. */ +export function resolveTrayProgressLines(args: { + meta: PluginMeta + data: PluginOutput + pluginSettings: PluginSettings + instanceId: string + preferWeeklyLimit?: boolean +}): ProgressLine[] { + const { meta, data, pluginSettings, instanceId, preferWeeklyLimit = false } = args + const configuredLabels = resolveConfiguredTrayLineLabels(pluginSettings, instanceId) + + if (configuredLabels !== undefined) { + const lines: ProgressLine[] = [] + for (const label of configuredLabels) { + const line = data.lines.find((l): l is ProgressLine => isProgressLine(l) && l.label === label) + if (line) lines.push(line) + } + return lines + } + + const primary = resolvePrimaryProgressLine({ + meta, + data, + pluginSettings, + instanceId, + preferWeeklyLimit, + }) + return primary ? [primary] : [] +} diff --git a/src/lib/provider-icon-url.test.ts b/src/lib/provider-icon-url.test.ts new file mode 100644 index 00000000..b64b9302 --- /dev/null +++ b/src/lib/provider-icon-url.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest" +import { isRasterProviderIconUrl } from "@/lib/provider-icon-url" + +describe("isRasterProviderIconUrl", () => { + it("detects png data urls", () => { + expect(isRasterProviderIconUrl("data:image/png;base64,abc")).toBe(true) + }) + + it("treats svg data urls as mask icons", () => { + expect(isRasterProviderIconUrl("data:image/svg+xml;base64,abc")).toBe(false) + }) +}) diff --git a/src/lib/provider-icon-url.ts b/src/lib/provider-icon-url.ts new file mode 100644 index 00000000..dffbdf27 --- /dev/null +++ b/src/lib/provider-icon-url.ts @@ -0,0 +1,6 @@ +/** Full-color raster icons (e.g. Cursor Nightly) must not use CSS mask + brandColor. */ +export function isRasterProviderIconUrl(iconUrl: string | undefined): boolean { + if (!iconUrl) return false + const trimmed = iconUrl.trim() + return /^data:image\/(png|jpe?g|webp);base64,/i.test(trimmed) +} diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 54d14c06..29c99b0a 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -121,7 +121,7 @@ describe("settings", () => { plugins ) expect(normalized).toEqual({ - order: ["b", "a"], + order: ["a", "b"], disabled: ["a"], trayLines: { "a": ["x"] }, providerInstances: {}, @@ -150,7 +150,7 @@ describe("settings", () => { plugins ) - expect(normalized.order).toEqual(["claude:personal", "cursor", "claude:work", "claude"]) + expect(normalized.order).toEqual(["claude", "claude:personal", "claude:work", "cursor"]) expect(normalized.disabled).toEqual(["claude:personal"]) expect(normalized.trayLines).toEqual({ "claude:work": ["Usage"] }) expect(normalized.providerInstances).toEqual({ @@ -193,6 +193,20 @@ describe("settings", () => { expect(normalized.trayLines).toEqual({ cursor: ["Total usage"] }) }) + it("sorts providers alphabetically by display name", () => { + const plugins: PluginMeta[] = [ + { id: "cursor", name: "Cursor", iconUrl: "", iconFilePath: "", lines: [], primaryCandidates: [] }, + { id: "claude", name: "Claude", iconUrl: "", iconFilePath: "", lines: [], primaryCandidates: [] }, + { id: "codex", name: "Codex", iconUrl: "", iconFilePath: "", lines: [], primaryCandidates: [] }, + { id: "cursor-nightly", name: "Cursor Nightly", iconUrl: "", iconFilePath: "", lines: [], primaryCandidates: [] }, + ] + const result = normalizePluginSettings( + { order: ["cursor", "codex", "claude", "cursor-nightly"], disabled: [] }, + plugins + ) + expect(result.order).toEqual(["claude", "codex", "cursor", "cursor-nightly"]) + }) + it("auto-disables new non-default plugins", () => { const plugins: PluginMeta[] = [ { id: "claude", name: "Claude", iconUrl: "", iconFilePath: "", lines: [], primaryCandidates: [] }, diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 351a1ff3..102f7978 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -66,6 +66,9 @@ export const USAGE_ALERT_ENABLED_KEY = "usageAlertEnabled"; export const USAGE_ALERT_THRESHOLD_KEY = "usageAlertThreshold"; export const USAGE_ALERT_CUSTOM_THRESHOLD_KEY = "usageAlertCustomThreshold"; export const USAGE_ALERT_SOUND_KEY = "usageAlertSound"; +export const USAGE_PACE_ALERT_ENABLED_KEY = "usagePaceAlertEnabled"; +export const USAGE_SPIKE_ALERT_ENABLED_KEY = "usageSpikeAlertEnabled"; +export const USAGE_SPIKE_ALERT_THRESHOLD_PCT_KEY = "usageSpikeAlertThresholdPct"; const UI_SCALE_KEY = "uiScale"; const SHOW_TRAY_ICON_KEY = "showTrayIcon"; @@ -87,6 +90,10 @@ export const DEFAULT_USAGE_ALERT_ENABLED = false; export const DEFAULT_USAGE_ALERT_THRESHOLD: UsageAlertThreshold = 20; export const DEFAULT_USAGE_ALERT_CUSTOM_THRESHOLD: number | null = null; export const DEFAULT_USAGE_ALERT_SOUND: UsageAlertSound = "Basso"; +export const DEFAULT_USAGE_PACE_ALERT_ENABLED = true; +export type UsageSpikeAlertThresholdPct = 25 | 50 | 100; +export const DEFAULT_USAGE_SPIKE_ALERT_ENABLED = false; +export const DEFAULT_USAGE_SPIKE_ALERT_THRESHOLD_PCT: UsageSpikeAlertThresholdPct = 50; export type UIScale = "normal" | "small" | "compact"; export const DEFAULT_UI_SCALE: UIScale = "normal"; @@ -334,7 +341,13 @@ export function normalizePluginSettings( } } - return { order, disabled, trayLines, providerInstances }; + const sortedOrder = sortPluginOrderAlphabetically( + order, + { providerInstances }, + plugins + ); + + return { order: sortedOrder, disabled, trayLines, providerInstances }; } export function arePluginSettingsEqual( @@ -590,6 +603,28 @@ function defaultProviderInstanceLabel(baseProviderId: string, instanceId: string return suffix || "Account"; } +/** Provider list order: A–Z by display name (base + account label). */ +export function sortPluginOrderAlphabetically( + order: string[], + settings: Pick, + plugins: PluginMeta[] +): string[] { + const partialSettings = { + order, + disabled: [], + trayLines: {}, + providerInstances: settings.providerInstances ?? {}, + } satisfies PluginSettings; + + return [...order].sort((a, b) => + getProviderDisplayName(a, partialSettings, plugins).localeCompare( + getProviderDisplayName(b, partialSettings, plugins), + undefined, + { sensitivity: "base" } + ) + ); +} + function isGlobalShortcut(value: unknown): value is GlobalShortcut { if (value === null) return true; return typeof value === "string"; @@ -678,6 +713,47 @@ export async function saveUsageAlertSound(value: UsageAlertSound): Promise await store.save(); } +export async function loadUsagePaceAlertEnabled(): Promise { + const stored = await store.get(USAGE_PACE_ALERT_ENABLED_KEY); + if (typeof stored === "boolean") return stored; + return DEFAULT_USAGE_PACE_ALERT_ENABLED; +} + +export async function saveUsagePaceAlertEnabled(value: boolean): Promise { + await store.set(USAGE_PACE_ALERT_ENABLED_KEY, value); + await store.save(); +} + +export function isUsageSpikeAlertThresholdPct( + value: unknown, +): value is UsageSpikeAlertThresholdPct { + return value === 25 || value === 50 || value === 100; +} + +export async function loadUsageSpikeAlertEnabled(): Promise { + const stored = await store.get(USAGE_SPIKE_ALERT_ENABLED_KEY); + if (typeof stored === "boolean") return stored; + return DEFAULT_USAGE_SPIKE_ALERT_ENABLED; +} + +export async function saveUsageSpikeAlertEnabled(value: boolean): Promise { + await store.set(USAGE_SPIKE_ALERT_ENABLED_KEY, value); + await store.save(); +} + +export async function loadUsageSpikeAlertThresholdPct(): Promise { + const stored = await store.get(USAGE_SPIKE_ALERT_THRESHOLD_PCT_KEY); + if (isUsageSpikeAlertThresholdPct(stored)) return stored; + return DEFAULT_USAGE_SPIKE_ALERT_THRESHOLD_PCT; +} + +export async function saveUsageSpikeAlertThresholdPct( + value: UsageSpikeAlertThresholdPct, +): Promise { + await store.set(USAGE_SPIKE_ALERT_THRESHOLD_PCT_KEY, value); + await store.save(); +} + export function isUIScale(value: unknown): value is UIScale { return typeof value === "string" && UI_SCALE_VALUES.includes(value as UIScale); } diff --git a/src/lib/tray-primary-progress.ts b/src/lib/tray-primary-progress.ts index 16e07529..20112384 100644 --- a/src/lib/tray-primary-progress.ts +++ b/src/lib/tray-primary-progress.ts @@ -1,6 +1,11 @@ import type { PluginMeta, PluginOutput } from "@/lib/plugin-types" import { getProviderInstanceMeta, type PluginSettings } from "@/lib/settings" import { DEFAULT_DISPLAY_MODE, type DisplayMode } from "@/lib/settings" +import { + isProgressLine, + resolveTrayProgressLines, + type ProgressLine, +} from "@/lib/primary-progress-line" import { clamp01 } from "@/lib/utils" type PluginState = { @@ -24,34 +29,15 @@ export type TrayPrimaryBar = { items: TrayPrimaryBarItem[] } -type ProgressLine = Extract< - PluginOutput["lines"][number], - { type: "progress"; label: string; used: number; limit: number } -> - -function isProgressLine(line: PluginOutput["lines"][number]): line is ProgressLine { - return line.type === "progress" -} - -function isWeeklyOverviewLine(meta: PluginMeta, label: string): boolean { - return meta.lines.some((line) => - line.type === "progress" && - line.scope === "overview" && - line.label === label && - /weekly/i.test(label) - ) -} - function pushProgressItem( items: TrayPrimaryBarItem[], label: string, line: ProgressLine, - displayMode: DisplayMode + displayMode: DisplayMode, ) { let fraction: number | undefined if (line.limit > 0) { - const shownAmount = - displayMode === "used" ? line.used : line.limit - line.used + const shownAmount = displayMode === "used" ? line.used : line.limit - line.used fraction = clamp01(shownAmount / line.limit) } if (line.format?.kind === "dollars") { @@ -103,43 +89,15 @@ export function getTrayPrimaryBars(args: { let items: TrayPrimaryBarItem[] = [] if (data) { - const configuredLabels = pluginSettings.trayLines?.[id] - - if (configuredLabels !== undefined) { - const targetLabels = - configuredLabels[0] === "__NONE__" ? [] : configuredLabels - - for (const targetLabel of targetLabels) { - const line = data.lines.find( - (l): l is ProgressLine => isProgressLine(l) && l.label === targetLabel - ) - if (line) { - pushProgressItem(items, targetLabel, line, displayMode) - } - } - } else { - const weeklyLabel = preferWeeklyLimit - ? data.lines - .filter(isProgressLine) - .find((line) => isWeeklyOverviewLine(meta, line.label)) - ?.label - : undefined - - const primaryLabel = - weeklyLabel ?? - meta.primaryCandidates.find((label) => - data.lines.some((line) => isProgressLine(line) && line.label === label) - ) - - if (primaryLabel) { - const primaryLine = data.lines.find( - (line): line is ProgressLine => - isProgressLine(line) && line.label === primaryLabel - ) - if (primaryLine) { - pushProgressItem(items, primaryLabel, primaryLine, displayMode) - } - } + const lines = resolveTrayProgressLines({ + meta, + data, + pluginSettings, + instanceId: id, + preferWeeklyLimit, + }) + for (const line of lines) { + pushProgressItem(items, line.label, line, displayMode) } } @@ -149,3 +107,5 @@ export function getTrayPrimaryBars(args: { return out } + +export { isProgressLine } diff --git a/src/lib/usage-daily-sparkline.ts b/src/lib/usage-daily-sparkline.ts new file mode 100644 index 00000000..7c7b7d94 --- /dev/null +++ b/src/lib/usage-daily-sparkline.ts @@ -0,0 +1,47 @@ +import type { BarChartPoint } from "@/lib/plugin-types" +import type { UsageDailyRow } from "@/lib/usage-daily" +import type { WeeklyRollupWindow } from "@/lib/weekly-rollup" + +function shortDayLabel(dayKey: string): string { + const parts = dayKey.split("-") + if (parts.length !== 3) return dayKey + return `${Number(parts[1])}/${Number(parts[2])}` +} + +function formatTokenLabel(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} + +/** Sum tokens across all accounts per calendar day within the window. */ +export function buildAggregatedSparklinePoints( + rows: UsageDailyRow[], + window: WeeklyRollupWindow, +): BarChartPoint[] { + const byDay = new Map() + for (const row of rows) { + if (row.dayKey < window.startDay || row.dayKey > window.endDay) continue + const tokens = row.totalTokens ?? 0 + byDay.set(row.dayKey, (byDay.get(row.dayKey) ?? 0) + tokens) + } + const sorted = [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])) + return sorted.map(([dayKey, value]) => ({ + label: shortDayLabel(dayKey), + value, + valueLabel: `${formatTokenLabel(value)} tokens`, + })) +} + +export function countDistinctDaysInWindow( + rows: UsageDailyRow[], + window: WeeklyRollupWindow, +): number { + const days = new Set() + for (const row of rows) { + if (row.dayKey >= window.startDay && row.dayKey <= window.endDay) { + days.add(row.dayKey) + } + } + return days.size +} diff --git a/src/lib/usage-insights.test.ts b/src/lib/usage-insights.test.ts new file mode 100644 index 00000000..a7fbc233 --- /dev/null +++ b/src/lib/usage-insights.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest" + +import { buildUsageInsights } from "@/lib/usage-insights" +import type { DisplayPluginState } from "@/hooks/app/use-app-plugin-views" + +const ONE_DAY = 86_400_000 + +function plugin(overrides: Partial & { data: DisplayPluginState["data"] }): DisplayPluginState { + return { + meta: { + id: "cursor", + name: "Cursor", + iconUrl: "", + iconFilePath: "", + primaryCandidates: ["Total usage"], + lines: [{ type: "progress", label: "Total usage", scope: "overview" }], + }, + loading: false, + error: null, + lastManualRefreshAt: null, + lastUpdatedAt: null, + ...overrides, + } +} + +describe("buildUsageInsights", () => { + it("returns empty without settings", () => { + expect(buildUsageInsights({ plugins: [], pluginSettings: null })).toEqual([]) + }) + + it("flags behind pace on primary percent line", () => { + const nowMs = Date.now() + const periodDurationMs = ONE_DAY * 7 + const resetsAt = new Date(nowMs + ONE_DAY * 3).toISOString() + const rows = buildUsageInsights({ + plugins: [ + plugin({ + data: { + providerId: "cursor", + displayName: "Cursor", + iconUrl: "", + lines: [ + { + type: "progress", + label: "Total usage", + used: 80, + limit: 100, + format: { kind: "percent" }, + resetsAt, + periodDurationMs, + }, + ], + }, + }), + ], + pluginSettings: { order: ["cursor"], disabled: [] }, + nowMs, + }) + expect(rows.some((r) => r.kind === "pace")).toBe(true) + }) + + it("includes tightest percent quota", () => { + const rows = buildUsageInsights({ + plugins: [ + plugin({ + data: { + providerId: "cursor", + displayName: "Cursor", + iconUrl: "", + lines: [ + { + type: "progress", + label: "Total usage", + used: 95, + limit: 100, + format: { kind: "percent" }, + }, + ], + }, + }), + ], + pluginSettings: { order: ["cursor"], disabled: [] }, + }) + expect(rows.some((r) => r.kind === "tight" && r.message.includes("5%"))).toBe(true) + }) +}) diff --git a/src/lib/usage-insights.ts b/src/lib/usage-insights.ts new file mode 100644 index 00000000..db88dc9e --- /dev/null +++ b/src/lib/usage-insights.ts @@ -0,0 +1,173 @@ +import type { DisplayPluginState } from "@/hooks/app/use-app-plugin-views" +import { calculatePaceStatus } from "@/lib/pace-status" +import { formatRunsOutText } from "@/lib/pace-tooltip" +import { formatResetRelativeLabel } from "@/lib/reset-tooltip" +import { resolvePrimaryProgressLine, isProgressLine } from "@/lib/primary-progress-line" +import type { PluginSettings } from "@/lib/settings" + +export type UsageInsightKind = "pace" | "tight" | "reset" + +export type UsageInsight = { + kind: UsageInsightKind + instanceId: string + displayName: string + lineLabel: string + message: string + /** Lower = higher priority within kind */ + sortKey: number + /** When set, dismiss hides this insight until this timestamp (ms). */ + dismissUntilMs?: number +} + +function parseResetsAtMs(line: { resetsAt?: string }): number | null { + if (!line.resetsAt) return null + const ms = Date.parse(line.resetsAt) + return Number.isFinite(ms) ? ms : null +} + +function remainingPercent(used: number, limit: number): number | null { + if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0) return null + return Math.max(0, Math.min(100, ((limit - used) / limit) * 100)) +} + +export function buildUsageInsights(args: { + plugins: DisplayPluginState[] + pluginSettings: PluginSettings | null + preferWeeklyLimit?: boolean + nowMs?: number + maxRows?: number +}): UsageInsight[] { + const { + plugins, + pluginSettings, + preferWeeklyLimit = false, + nowMs = Date.now(), + maxRows = 3, + } = args + if (!pluginSettings) return [] + + const paceRows: UsageInsight[] = [] + const tightCandidates: UsageInsight[] = [] + const resetCandidates: { insight: UsageInsight; resetsAtMs: number }[] = [] + + for (const plugin of plugins) { + if (!plugin.data || plugin.loading || plugin.error) continue + + const instanceId = plugin.meta.id + const displayName = plugin.data.displayName || plugin.meta.name + + const primary = resolvePrimaryProgressLine({ + meta: plugin.meta, + data: plugin.data, + pluginSettings, + instanceId, + preferWeeklyLimit, + }) + + if (primary) { + const resetsAtMs = parseResetsAtMs(primary) + const periodDurationMs = primary.periodDurationMs + + if ( + primary.format?.kind === "percent" && + resetsAtMs != null && + periodDurationMs != null && + periodDurationMs > 0 + ) { + const pace = calculatePaceStatus( + primary.used, + primary.limit, + resetsAtMs, + periodDurationMs, + nowMs, + ) + if (pace?.status === "behind") { + const runsOut = formatRunsOutText({ + paceResult: pace, + used: primary.used, + limit: primary.limit, + periodDurationMs, + resetsAtMs, + nowMs, + }) + paceRows.push({ + kind: "pace", + instanceId, + displayName, + lineLabel: primary.label, + message: runsOut + ? `${displayName} (${primary.label}) — ${runsOut}` + : `${displayName} (${primary.label}) — projected to run out before reset`, + sortKey: pace.projectedUsage - primary.limit, + dismissUntilMs: resetsAtMs ?? undefined, + }) + } + } + + if (primary.format?.kind === "percent") { + const rem = remainingPercent(primary.used, primary.limit) + if (rem != null) { + tightCandidates.push({ + kind: "tight", + instanceId, + displayName, + lineLabel: primary.label, + message: `${displayName} (${primary.label}) — ${rem.toFixed(0)}% remaining`, + sortKey: rem, + dismissUntilMs: nowMs + 86_400_000, + }) + } + } + } + + for (const line of plugin.data.lines) { + if (!isProgressLine(line)) continue + const resetsAtMs = parseResetsAtMs(line) + if (resetsAtMs == null || resetsAtMs <= nowMs) continue + const delta = resetsAtMs - nowMs + const resetLabel = formatResetRelativeLabel(nowMs, line.resetsAt!) + resetCandidates.push({ + resetsAtMs, + insight: { + kind: "reset", + instanceId, + displayName, + lineLabel: line.label, + message: resetLabel + ? `${displayName} (${line.label}) — ${resetLabel}` + : `${displayName} (${line.label}) resets soon`, + sortKey: delta, + dismissUntilMs: resetsAtMs, + }, + }) + } + } + + paceRows.sort((a, b) => b.sortKey - a.sortKey) + tightCandidates.sort((a, b) => a.sortKey - b.sortKey) + resetCandidates.sort((a, b) => a.insight.sortKey - b.insight.sortKey) + + const out: UsageInsight[] = [] + const seen = new Set() + + const pushUnique = (row: UsageInsight) => { + const key = `${row.kind}:${row.instanceId}:${row.lineLabel}` + if (seen.has(key)) return + seen.add(key) + out.push(row) + } + + for (const row of paceRows) { + if (out.length >= maxRows) break + pushUnique(row) + } + for (const row of tightCandidates) { + if (out.length >= maxRows) break + pushUnique(row) + } + if (resetCandidates[0] && out.length < maxRows) { + pushUnique(resetCandidates[0].insight) + } + + return out +} diff --git a/src/lib/weekly-rollup.test.ts b/src/lib/weekly-rollup.test.ts new file mode 100644 index 00000000..9a014bf3 --- /dev/null +++ b/src/lib/weekly-rollup.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest" + +import { + computeRollingRollup, + computeWeeklyRollup, + formatWeeklyRollupSummary, + rollingSevenDayWindows, +} from "@/lib/weekly-rollup" +import type { UsageDailyRow } from "@/lib/usage-daily" + +describe("weekly-rollup", () => { + it("computes rolling 7d windows", () => { + const now = new Date(2026, 5, 10) + const w = rollingSevenDayWindows(now) + expect(w.current.endDay).toBe("2026-06-10") + expect(w.current.startDay).toBe("2026-06-04") + expect(w.prior.endDay).toBe("2026-06-03") + expect(w.prior.startDay).toBe("2026-05-28") + }) + + it("aggregates tokens and delta", () => { + const now = new Date(2026, 5, 10) + const rows: UsageDailyRow[] = [ + { + instanceId: "claude", + dayKey: "2026-06-10", + displayName: "Claude", + totalTokens: 1000, + inputTokens: null, + outputTokens: null, + costUsd: 1, + source: "ccusage", + ingestedAtMs: 0, + }, + { + instanceId: "claude", + dayKey: "2026-06-03", + displayName: "Claude", + totalTokens: 500, + inputTokens: null, + outputTokens: null, + costUsd: 0.5, + source: "ccusage", + ingestedAtMs: 0, + }, + ] + const rollup = computeWeeklyRollup(rows, now) + expect(rollup?.current.totalTokens).toBe(1000) + expect(rollup?.prior.totalTokens).toBe(500) + expect(rollup?.tokenDeltaPct).toBe(100) + expect(formatWeeklyRollupSummary(rollup!)).toContain("This 7d:") + expect(rollup?.windowDays).toBe(7) + }) + + it("computes 30d rollup", () => { + const now = new Date(2026, 5, 30) + const rows: UsageDailyRow[] = Array.from({ length: 10 }, (_, i) => ({ + instanceId: "claude", + dayKey: `2026-06-${String(21 + i).padStart(2, "0")}`, + displayName: "Claude", + totalTokens: 100, + inputTokens: null, + outputTokens: null, + costUsd: 0.1, + source: "ccusage", + ingestedAtMs: 0, + })) + const rollup = computeRollingRollup(rows, 30, now) + expect(rollup?.windowDays).toBe(30) + expect(rollup?.current.totalTokens).toBeGreaterThan(0) + }) +}) diff --git a/src/lib/weekly-rollup.ts b/src/lib/weekly-rollup.ts new file mode 100644 index 00000000..8cd8d2eb --- /dev/null +++ b/src/lib/weekly-rollup.ts @@ -0,0 +1,166 @@ +import type { UsageDailyRow } from "@/lib/usage-daily" + +export type WeeklyRollupWindow = { + /** Inclusive YYYY-MM-DD */ + startDay: string + endDay: string +} + +export type WeeklyRollupTotals = { + totalTokens: number + costUsd: number +} + +export type WeeklyRollupResult = { + windowDays: number + current: WeeklyRollupTotals + prior: WeeklyRollupTotals + tokenDeltaPct: number | null + costDeltaPct: number | null + topContributors: { displayName: string; totalTokens: number }[] + currentWindow: WeeklyRollupWindow + priorWindow: WeeklyRollupWindow +} + +function dayKeyToDate(dayKey: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dayKey.trim()) + if (!m) return null + const y = Number(m[1]) + const mo = Number(m[2]) - 1 + const d = Number(m[3]) + const dt = new Date(y, mo, d) + if (dt.getFullYear() !== y || dt.getMonth() !== mo || dt.getDate() !== d) return null + return dt +} + +function formatDayKey(dt: Date): string { + const y = dt.getFullYear() + const m = String(dt.getMonth() + 1).padStart(2, "0") + const d = String(dt.getDate()).padStart(2, "0") + return `${y}-${m}-${d}` +} + +export function rollingWindowDays( + windowDays: number, + now: Date = new Date(), +): { current: WeeklyRollupWindow; prior: WeeklyRollupWindow } { + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const currentStart = new Date(end) + currentStart.setDate(currentStart.getDate() - (windowDays - 1)) + const priorEnd = new Date(currentStart) + priorEnd.setDate(priorEnd.getDate() - 1) + const priorStart = new Date(priorEnd) + priorStart.setDate(priorStart.getDate() - (windowDays - 1)) + + return { + current: { startDay: formatDayKey(currentStart), endDay: formatDayKey(end) }, + prior: { startDay: formatDayKey(priorStart), endDay: formatDayKey(priorEnd) }, + } +} + +/** @deprecated use rollingWindowDays(7) */ +export function rollingSevenDayWindows(now: Date = new Date()) { + return rollingWindowDays(7, now) +} + +function inWindow(dayKey: string, window: WeeklyRollupWindow): boolean { + return dayKey >= window.startDay && dayKey <= window.endDay +} + +function deltaPct(current: number, prior: number): number | null { + if (prior <= 0) return current > 0 ? 100 : null + return Math.round(((current - prior) / prior) * 100) +} + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tokens` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K tokens` + return `${n} tokens` +} + +function formatDeltaPct(pct: number | null, priorLabel: string): string { + if (pct == null) return "" + const arrow = pct > 0 ? "↑" : pct < 0 ? "↓" : "→" + return ` (${arrow} ${Math.abs(pct)}% vs prior ${priorLabel})` +} + +export function computeRollingRollup( + rows: UsageDailyRow[], + windowDays: number, + now: Date = new Date(), +): WeeklyRollupResult | null { + if (rows.length === 0) return null + + const { current: currentWindow, prior: priorWindow } = rollingWindowDays(windowDays, now) + const current: WeeklyRollupTotals = { totalTokens: 0, costUsd: 0 } + const prior: WeeklyRollupTotals = { totalTokens: 0, costUsd: 0 } + const byNameCurrent = new Map() + + for (const row of rows) { + if (!dayKeyToDate(row.dayKey)) continue + const tokens = row.totalTokens ?? 0 + const cost = row.costUsd ?? 0 + if (inWindow(row.dayKey, currentWindow)) { + current.totalTokens += tokens + current.costUsd += cost + byNameCurrent.set( + row.displayName, + (byNameCurrent.get(row.displayName) ?? 0) + tokens, + ) + } else if (inWindow(row.dayKey, priorWindow)) { + prior.totalTokens += tokens + prior.costUsd += cost + } + } + + if (current.totalTokens === 0 && current.costUsd === 0 && prior.totalTokens === 0) { + return null + } + + const topContributors = [...byNameCurrent.entries()] + .map(([displayName, totalTokens]) => ({ displayName, totalTokens })) + .sort((a, b) => b.totalTokens - a.totalTokens) + .slice(0, 3) + + return { + windowDays, + current, + prior, + tokenDeltaPct: deltaPct(current.totalTokens, prior.totalTokens), + costDeltaPct: deltaPct(current.costUsd, prior.costUsd), + topContributors, + currentWindow, + priorWindow, + } +} + +/** @deprecated use computeRollingRollup(rows, 7) */ +export function computeWeeklyRollup( + rows: UsageDailyRow[], + now: Date = new Date(), +): WeeklyRollupResult | null { + return computeRollingRollup(rows, 7, now) +} + +export function formatRollupSummary(rollup: WeeklyRollupResult): string { + const label = rollup.windowDays === 7 ? "7d" : `${rollup.windowDays}d` + const priorLabel = rollup.windowDays === 7 ? "7d" : `${rollup.windowDays}d` + + const tokens = formatTokenCount(rollup.current.totalTokens) + const tokenDelta = formatDeltaPct(rollup.tokenDeltaPct, priorLabel) + + let costPart = "" + if (rollup.current.costUsd > 0) { + const costDelta = formatDeltaPct(rollup.costDeltaPct, priorLabel) + costPart = ` · ~$${rollup.current.costUsd.toFixed(2)}${costDelta}` + } + + return `This ${label}: ${tokens}${tokenDelta}${costPart}` +} + +/** @deprecated use formatRollupSummary */ +export function formatWeeklyRollupSummary(rollup: WeeklyRollupResult): string { + return formatRollupSummary(rollup) +} + +export { dayKeyToDate } diff --git a/src/pages/overview.test.tsx b/src/pages/overview.test.tsx index ae708eda..25857e76 100644 --- a/src/pages/overview.test.tsx +++ b/src/pages/overview.test.tsx @@ -1,12 +1,35 @@ import { render, screen } from "@testing-library/react" -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { OverviewPage } from "@/pages/overview" +vi.mock("@/hooks/use-weekly-rollup", () => ({ + useWeeklyRollup: () => ({ + dailyRows: [], + rollup: null, + rollup30: null, + scheduleReload: vi.fn(), + }), +})) + +vi.mock("@/hooks/use-spend-spike-alert", () => ({ + useSpendSpikeAlert: () => ({ checkSpendSpike: vi.fn() }), +})) + +vi.mock("@/stores/app-ui-store", () => ({ + useAppUiStore: (selector: (s: { setActiveView: () => void }) => unknown) => + selector({ setActiveView: vi.fn() }), +})) + +vi.mock("@/hooks/use-persist-usage-history", () => ({ + usePersistUsageHistory: () => false, +})) + describe("OverviewPage", () => { it("renders empty state", () => { render( @@ -28,6 +51,7 @@ describe("OverviewPage", () => { render( @@ -65,6 +89,7 @@ describe("OverviewPage", () => { render( @@ -96,6 +121,7 @@ describe("OverviewPage", () => { render( diff --git a/src/pages/overview.tsx b/src/pages/overview.tsx index 7440adae..0905309a 100644 --- a/src/pages/overview.tsx +++ b/src/pages/overview.tsx @@ -1,8 +1,20 @@ +import { useEffect, useMemo } from "react" import { ProviderCard } from "@/components/provider-card" +import { UsageInsightsBanner } from "@/components/usage-insights-banner" +import { useSpendSpikeAlert } from "@/hooks/use-spend-spike-alert" +import { useWeeklyRollup } from "@/hooks/use-weekly-rollup" +import { usePersistUsageHistory } from "@/hooks/use-persist-usage-history" +import { useNowTicker } from "@/hooks/use-now-ticker" +import { useAppUiStore } from "@/stores/app-ui-store" import type { PluginDisplayState } from "@/lib/plugin-types" -import type { DisplayMode, ResetTimerDisplayMode, TimeFormatMode } from "@/lib/settings" +import { buildUsageInsights } from "@/lib/usage-insights" +import type { DisplayMode, PluginSettings, ResetTimerDisplayMode, TimeFormatMode } from "@/lib/settings" + interface OverviewPageProps { plugins: PluginDisplayState[] + pluginSettings: PluginSettings | null + preferWeeklyLimit?: boolean + onProbeComplete?: () => void onRetryPlugin?: (pluginId: string) => void displayMode: DisplayMode resetTimerDisplayMode: ResetTimerDisplayMode @@ -13,6 +25,9 @@ interface OverviewPageProps { export function OverviewPage({ plugins, + pluginSettings, + preferWeeklyLimit = false, + onProbeComplete, onRetryPlugin, displayMode, resetTimerDisplayMode, @@ -20,6 +35,33 @@ export function OverviewPage({ onResetTimerDisplayModeToggle, showAccountIdentity, }: OverviewPageProps) { + const nowMs = useNowTicker() + const persistEnabled = usePersistUsageHistory() + const { dailyRows, rollup, rollup30, scheduleReload } = useWeeklyRollup(persistEnabled) + const { checkSpendSpike } = useSpendSpikeAlert() + const setActiveView = useAppUiStore((state) => state.setActiveView) + + const probeStamp = plugins.map((p) => p.lastUpdatedAt ?? 0).join(",") + useEffect(() => { + onProbeComplete?.() + scheduleReload() + }, [probeStamp, onProbeComplete, scheduleReload]) + + useEffect(() => { + checkSpendSpike(rollup) + }, [rollup, checkSpendSpike]) + + const insights = useMemo( + () => + buildUsageInsights({ + plugins, + pluginSettings, + preferWeeklyLimit, + nowMs, + }), + [plugins, pluginSettings, preferWeeklyLimit, nowMs], + ) + if (plugins.length === 0) { return (
@@ -30,6 +72,15 @@ export function OverviewPage({ return (
+ {plugins.map((plugin, index) => ( +
+ + {isCursorFamily ? : null} +
) } diff --git a/src/pages/settings.test.tsx b/src/pages/settings.test.tsx index 098866ce..637c00a1 100644 --- a/src/pages/settings.test.tsx +++ b/src/pages/settings.test.tsx @@ -82,8 +82,26 @@ const defaultProps = { onGlobalShortcutChange: vi.fn(), startOnLogin: false, onStartOnLoginChange: vi.fn(), + usageAlertEnabled: false, + onUsageAlertEnabledChange: vi.fn(), + usageAlertThreshold: 20 as const, + onUsageAlertThresholdChange: vi.fn(), + customUsageAlertThreshold: null, + onUsageAlertCustomThresholdChange: vi.fn(), + usageAlertSound: "Basso" as const, + onUsageAlertSoundChange: vi.fn(), + usagePaceAlertEnabled: true, + onUsagePaceAlertEnabledChange: vi.fn(), + usageSpikeAlertEnabled: false, + onUsageSpikeAlertEnabledChange: vi.fn(), + usageSpikeAlertThresholdPct: 50 as const, + onUsageSpikeAlertThresholdPctChange: vi.fn(), + uiScale: "normal" as const, + onUIScaleChange: vi.fn(), showAccountIdentity: true, onShowAccountIdentityChange: vi.fn(), + cursorRequestsLineAvailable: null, + onSetCursorTrayMetricForAllAccounts: vi.fn(), } afterEach(() => { diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index cad400b9..3dd52276 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -21,6 +21,7 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; +import { ProviderIcon } from "@/components/provider-icon"; import { GlobalShortcutSection } from "@/components/global-shortcut-section"; import { getBarFillLayout } from "@/lib/tray-bars-icon"; import { getTrayIconSizePx } from "@/lib/tray-icon-size"; @@ -49,6 +50,7 @@ import { } from "@/lib/settings"; import { formatLogTailClipboard } from "@/lib/support-issue-paste"; import type { UsageHistoryRow } from "@/lib/usage-history"; +import { exportUsageHistoryToFolder } from "@/lib/history-export"; import type { UsageDailyRow } from "@/lib/usage-daily"; import { UsageHistoryChart, usageHistoryInstanceOptions } from "@/components/usage-history-chart"; import { UsageDailyChart, usageDailyInstanceOptions } from "@/components/usage-daily-chart"; @@ -160,32 +162,13 @@ function ProviderIconMask({ sizePx: number; className?: string; }) { - const colorClass = isActive ? "bg-primary-foreground" : "bg-foreground"; - if (iconUrl) { - return ( -
- ); - } - const textClass = isActive ? "text-primary-foreground" : "text-foreground"; return ( - - - + ); } @@ -670,6 +653,8 @@ function UsageHistorySection() { const [rows, setRows] = useState([]); const [dailyRows, setDailyRows] = useState([]); const [msg, setMsg] = useState(null); + const [exportMsg, setExportMsg] = useState(null); + const [exporting, setExporting] = useState(false); const [chartInstanceFilter, setChartInstanceFilter] = useState("all"); const reload = useCallback(async () => { @@ -742,8 +727,8 @@ function UsageHistorySection() {

Optional local SQLite history on this device — never uploaded. With saving enabled:{" "} quota snapshots after each successful refresh (~one per account per 32s), plus{" "} - daily token totals from Claude/Codex local logs (same data as the card Usage Trend, via ccusage). - Cursor and other providers get quota snapshots only unless they expose log-based daily usage later. + daily token totals from Claude/Codex local logs (ccusage) and Cursor local transcripts (token counts only — no dollar cost in export). + For Cursor billing dollars, use the Cursor detail page → Billing usage table.