diff --git a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs index 4e40926ca2..631a582131 100644 --- a/apps/desktop-tauri/src-tauri/src/auto_refresh.rs +++ b/apps/desktop-tauri/src-tauri/src/auto_refresh.rs @@ -58,10 +58,6 @@ pub fn install(app: tauri::AppHandle) { .unwrap_or(now); if now >= scheduled_at { let _ = crate::commands::do_refresh_providers_if_stale(&app).await; - // Treat a completed refresh as a weak activity signal while adaptive. - if adaptive { - note_coding_activity(); - } let next_interval = resolve_refresh_interval(&Settings::load()).unwrap_or(interval); schedule = Some(( @@ -72,6 +68,14 @@ pub fn install(app: tauri::AppHandle) { } } } + // Sample coding-agent processes on each poll while Adaptive is on + // so delays can drop to the 5m coding-activity cap without waiting + // for a refresh tick. + if ADAPTIVE_ACTIVE.load(Ordering::Relaxed) + && crate::coding_activity::coding_agent_process_active() + { + note_coding_activity(); + } tokio::time::sleep(AUTO_REFRESH_POLL_INTERVAL).await; } }); diff --git a/apps/desktop-tauri/src-tauri/src/coding_activity.rs b/apps/desktop-tauri/src-tauri/src/coding_activity.rs new file mode 100644 index 0000000000..341a5dcf40 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/coding_activity.rs @@ -0,0 +1,124 @@ +//! Best-effort coding-agent process detection for Adaptive refresh (Windows). +//! +//! Looks for well-known agent process names. Does not inspect command lines or +//! env (no secret leakage). Consent is the Settings Adaptive toggle itself. + +/// Process base names (lowercase, without `.exe`) that count as coding activity. +const AGENT_PROCESS_NAMES: &[&str] = &[ + "claude", + "codex", + "cursor", + "cursor-agent", + "gemini", + "opencode", + "aider", + "continue", + "windsurf", + "codeium", + "copilot", +]; + +/// Returns true when at least one known coding-agent process is running. +pub fn coding_agent_process_active() -> bool { + #[cfg(windows)] + { + windows_agent_running() + } + #[cfg(not(windows))] + { + false + } +} + +#[cfg(windows)] +fn windows_agent_running() -> bool { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + + #[repr(C)] + struct ProcessEntry32W { + dw_size: u32, + cnt_usage: u32, + th32_process_id: u32, + th32_default_heap_id: usize, + th32_module_id: u32, + cnt_threads: u32, + th32_parent_process_id: u32, + pc_pri_class_base: i32, + dw_flags: u32, + sz_exe_file: [u16; 260], + } + + const TH32CS_SNAPPROCESS: u32 = 0x0000_0002; + const INVALID_HANDLE_VALUE: *mut std::ffi::c_void = -1isize as *mut std::ffi::c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateToolhelp32Snapshot( + dw_flags: u32, + th32_process_id: u32, + ) -> *mut std::ffi::c_void; + fn Process32FirstW(snapshot: *mut std::ffi::c_void, entry: *mut ProcessEntry32W) -> i32; + fn Process32NextW(snapshot: *mut std::ffi::c_void, entry: *mut ProcessEntry32W) -> i32; + fn CloseHandle(handle: *mut std::ffi::c_void) -> i32; + } + + unsafe { + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap.is_null() || snap == INVALID_HANDLE_VALUE { + return false; + } + let mut entry = ProcessEntry32W { + dw_size: std::mem::size_of::() as u32, + cnt_usage: 0, + th32_process_id: 0, + th32_default_heap_id: 0, + th32_module_id: 0, + cnt_threads: 0, + th32_parent_process_id: 0, + pc_pri_class_base: 0, + dw_flags: 0, + sz_exe_file: [0; 260], + }; + let mut found = false; + if Process32FirstW(snap, &mut entry) != 0 { + loop { + let len = entry + .sz_exe_file + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.sz_exe_file.len()); + let name = OsString::from_wide(&entry.sz_exe_file[..len]) + .to_string_lossy() + .to_ascii_lowercase(); + let stem = name.strip_suffix(".exe").unwrap_or(&name); + if AGENT_PROCESS_NAMES.iter().any(|n| *n == stem) { + found = true; + break; + } + if Process32NextW(snap, &mut entry) == 0 { + break; + } + } + } + let _ = CloseHandle(snap); + found + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn agent_name_list_is_nonempty() { + assert!(!AGENT_PROCESS_NAMES.is_empty()); + assert!(AGENT_PROCESS_NAMES.contains(&"claude")); + assert!(AGENT_PROCESS_NAMES.contains(&"codex")); + } + + #[test] + fn detection_does_not_panic() { + let _ = coding_agent_process_active(); + } +} diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index eee3102e3e..cd46ddf69d 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -3,6 +3,7 @@ use std::time::Duration; mod auto_refresh; +mod coding_activity; mod commands; mod events; mod floatbar; diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index baaea8eb37..6f977b76a6 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -297,6 +297,9 @@ export const ALL_LOCALE_KEYS = [ "UsageSpendCol30d", "UsageSpendColCurrency", "UsageSpendColSource", + "UsageSpendShareFailed", + "UsageSpendShareEmpty", + "UsageSpendShare", "AgentSessionsTitle", "AgentSessionsEnableLabel", "AgentSessionsEnableHelper", diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 0e0710fd66..7043310bfb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; import { getUsageSpendSummary } from "../../../lib/tauri"; import type { UsageSpendSummary } from "../../../types/bridge"; @@ -17,11 +17,105 @@ function formatUsd(value: number | null | undefined, currency: string): string { } } +/** Sanitized share-card PNG (no account emails) — upstream #2112. */ +function renderSharePng(summary: UsageSpendSummary, title: string): string { + const rows = summary.rows ?? []; + const pad = 24; + const rowH = 28; + const headerH = 48; + const colW = [160, 100, 100, 80, 160]; + const width = pad * 2 + colW.reduce((a, b) => a + b, 0); + const height = pad * 2 + headerH + Math.max(1, rows.length) * rowH + 36; + const canvas = document.createElement("canvas"); + canvas.width = width * 2; + canvas.height = height * 2; + const ctx = canvas.getContext("2d"); + if (!ctx) return ""; + ctx.scale(2, 2); + + // Background + ctx.fillStyle = "#0f1419"; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = "#243044"; + ctx.lineWidth = 1; + ctx.strokeRect(0.5, 0.5, width - 1, height - 1); + + ctx.fillStyle = "#e7ecf3"; + ctx.font = "600 16px system-ui,Segoe UI,sans-serif"; + ctx.fillText(title, pad, pad + 18); + + ctx.fillStyle = "#8b9bb4"; + ctx.font = "12px system-ui,Segoe UI,sans-serif"; + ctx.fillText("Win-CodexBar · local estimates · no account emails", pad, pad + 36); + + const headers = ["Provider", "7 days", "30 days", "Currency", "Source"]; + let x = pad; + const y0 = pad + headerH; + ctx.fillStyle = "#9fb0c8"; + ctx.font = "600 12px system-ui,Segoe UI,sans-serif"; + headers.forEach((h, i) => { + ctx.fillText(h, x, y0); + x += colW[i]; + }); + + ctx.strokeStyle = "#243044"; + ctx.beginPath(); + ctx.moveTo(pad, y0 + 8); + ctx.lineTo(width - pad, y0 + 8); + ctx.stroke(); + + ctx.font = "13px system-ui,Segoe UI,sans-serif"; + if (rows.length === 0) { + ctx.fillStyle = "#8b9bb4"; + ctx.fillText("No spend data yet.", pad, y0 + rowH); + } else { + rows.forEach((row, idx) => { + const y = y0 + (idx + 1) * rowH; + const cells = [ + row.displayName, + formatUsd(row.sevenDay, row.currency), + formatUsd(row.thirtyDay, row.currency), + row.currency || "USD", + row.source, + ]; + let cx = pad; + cells.forEach((cell, i) => { + ctx.fillStyle = i === 0 ? "#e7ecf3" : "#c5d0e0"; + const text = String(cell); + const max = colW[i] - 8; + let draw = text; + if (ctx.measureText(draw).width > max) { + while (draw.length > 1 && ctx.measureText(`${draw}…`).width > max) { + draw = draw.slice(0, -1); + } + draw = `${draw}…`; + } + ctx.fillText(draw, cx, y); + cx += colW[i]; + }); + }); + } + + return canvas.toDataURL("image/png"); +} + +function downloadDataUrl(dataUrl: string, filename: string) { + const a = document.createElement("a"); + a.href = dataUrl; + a.download = filename; + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); +} + export default function UsageSpendTab(_props: TabProps) { const { t } = useLocale(); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [shareError, setShareError] = useState(null); + const tableRef = useRef(null); const load = useCallback(() => { setLoading(true); @@ -41,6 +135,25 @@ export default function UsageSpendTab(_props: TabProps) { load(); }, [load]); + const onShare = useCallback(() => { + setShareError(null); + if (!summary) { + setShareError(t("UsageSpendShareEmpty")); + return; + } + try { + const dataUrl = renderSharePng(summary, t("UsageSpendTitle")); + if (!dataUrl) { + setShareError(t("UsageSpendShareFailed")); + return; + } + const stamp = new Date().toISOString().slice(0, 10); + downloadDataUrl(dataUrl, `codexbar-usage-spend-${stamp}.png`); + } catch { + setShareError(t("UsageSpendShareFailed")); + } + }, [summary, t]); + return (

@@ -48,7 +161,7 @@ export default function UsageSpendTab(_props: TabProps) {

{t("UsageSpendCaption")}

-
+
+
{error &&

{error}

} + {shareError &&

{shareError}

} {!error && ( - +
diff --git a/rust/src/cli/hooks.rs b/rust/src/cli/hooks.rs new file mode 100644 index 0000000000..735019195f --- /dev/null +++ b/rust/src/cli/hooks.rs @@ -0,0 +1,324 @@ +//! `codexbar hooks` — list / enable / disable / test external hook rules. + +use clap::{Args, Subcommand}; +use serde::Serialize; + +use crate::core::{HookEvent, HookEventType, HookRunner, HooksConfig, ProviderId}; +use crate::settings::Settings; + +#[derive(Args, Debug, Clone)] +pub struct HooksArgs { + #[command(subcommand)] + pub command: HooksCommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum HooksCommand { + /// Print configured hook rules + List(HooksListArgs), + /// Enable hooks in hooks.json (master switch) + Enable(HooksToggleArgs), + /// Disable hooks in hooks.json (master switch) + Disable(HooksToggleArgs), + /// Run matching rules for a sample event + Test(HooksTestArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct HooksListArgs { + /// Emit JSON + #[arg(long)] + pub json: bool, + /// Pretty-print JSON + #[arg(long)] + pub pretty: bool, +} + +#[derive(Args, Debug, Clone)] +pub struct HooksToggleArgs { + /// Emit JSON + #[arg(long)] + pub json: bool, + /// Pretty-print JSON + #[arg(long)] + pub pretty: bool, +} + +#[derive(Args, Debug, Clone)] +pub struct HooksTestArgs { + /// Event name (quota_low, quota_reached, quota_reset, provider_unavailable, provider_recovered, refresh_failed) + pub event: String, + /// Provider CLI name + #[arg(long)] + pub provider: String, + /// Emit JSON + #[arg(long)] + pub json: bool, + /// Pretty-print JSON + #[arg(long)] + pub pretty: bool, +} + +#[derive(Debug, Serialize)] +struct HooksListJson { + enabled: bool, + settings_hooks_enabled: bool, + path: Option, + rules: Vec, +} + +#[derive(Debug, Serialize)] +struct HookRuleListItem { + enabled: bool, + event: Option, + events: Vec, + provider: Option, + executable: String, + arguments: Vec, + timeout_secs: u64, +} + +#[derive(Debug, Serialize)] +struct HookTestResult { + executable: String, + event: String, + provider: String, + ok: bool, + error: Option, +} + +pub async fn run(args: HooksArgs) -> anyhow::Result<()> { + match args.command { + HooksCommand::List(a) => run_list(a), + HooksCommand::Enable(a) => run_set_enabled(true, a), + HooksCommand::Disable(a) => run_set_enabled(false, a), + HooksCommand::Test(a) => run_test(a), + } +} + +fn run_list(args: HooksListArgs) -> anyhow::Result<()> { + let config = HooksConfig::load(); + let settings = Settings::load(); + let path = HooksConfig::path().map(|p| p.display().to_string()); + + if args.json { + let payload = HooksListJson { + enabled: config.enabled, + settings_hooks_enabled: settings.hooks_enabled, + path, + rules: config + .events + .iter() + .map(|r| HookRuleListItem { + enabled: r.enabled, + event: r.event.map(|e| e.as_str().to_string()), + events: r + .events + .iter() + .map(|e| e.as_str().to_string()) + .collect(), + provider: r.provider.clone(), + executable: r.executable.display().to_string(), + arguments: r.arguments.clone(), + timeout_secs: r.timeout_secs, + }) + .collect(), + }; + print_json(&payload, args.pretty)?; + return Ok(()); + } + + println!( + "Hooks: {} (settings toggle: {})", + if config.enabled { "enabled" } else { "disabled" }, + if settings.hooks_enabled { + "on" + } else { + "off" + } + ); + if let Some(path) = path { + println!("Config: {path}"); + } + if config.events.is_empty() { + println!("No rules configured."); + return Ok(()); + } + for rule in &config.events { + let state = if rule.enabled { "on" } else { "off" }; + let event = rule + .event + .map(|e| e.as_str().to_string()) + .or_else(|| { + (!rule.events.is_empty()).then(|| { + rule.events + .iter() + .map(|e| e.as_str()) + .collect::>() + .join(",") + }) + }) + .unwrap_or_else(|| "any".into()); + let provider = rule.provider.as_deref().unwrap_or("any"); + let command = std::iter::once(rule.executable.display().to_string()) + .chain(rule.arguments.iter().cloned()) + .collect::>() + .join(" "); + println!("[{state}] {event} provider={provider}: {command}"); + } + Ok(()) +} + +fn run_set_enabled(enabled: bool, args: HooksToggleArgs) -> anyhow::Result<()> { + let mut config = HooksConfig::load(); + config.enabled = enabled; + let path = config.save().map_err(anyhow::Error::msg)?; + if args.json { + print_json( + &serde_json::json!({ + "enabled": config.enabled, + "path": path.display().to_string(), + }), + args.pretty, + )?; + } else { + println!( + "Hooks: {} ({})", + if enabled { "enabled" } else { "disabled" }, + path.display() + ); + } + Ok(()) +} + +fn run_test(args: HooksTestArgs) -> anyhow::Result<()> { + let event_type = parse_event(&args.event)?; + let provider = ProviderId::from_cli_name(&args.provider).ok_or_else(|| { + anyhow::anyhow!( + "Unknown provider '{}'. Use a CLI name such as codex or claude.", + args.provider + ) + })?; + + let event = sample_event(event_type, provider.cli_name()); + let config = HooksConfig::load(); + if !config.enabled { + anyhow::bail!("Hooks are disabled. Run `codexbar hooks enable` first."); + } + let settings = Settings::load(); + if !settings.hooks_enabled { + anyhow::bail!( + "Hooks are disabled in Settings (hooks_enabled=false). Enable them in Advanced." + ); + } + + let rules = config.matching_rules(&event); + if rules.is_empty() { + anyhow::bail!( + "No hook rule matches {} for {}.", + event_type.as_str(), + provider.cli_name() + ); + } + + let base_env = std::env::vars().collect(); + let mut results = Vec::new(); + for rule in rules { + match HookRunner::run(rule, &event, &base_env) { + Ok(()) => results.push(HookTestResult { + executable: rule.executable.display().to_string(), + event: event_type.as_str().into(), + provider: provider.cli_name().into(), + ok: true, + error: None, + }), + Err(err) => results.push(HookTestResult { + executable: rule.executable.display().to_string(), + event: event_type.as_str().into(), + provider: provider.cli_name().into(), + ok: false, + error: Some(err), + }), + } + } + + if args.json { + print_json(&results, args.pretty)?; + } else { + for r in &results { + if r.ok { + println!("ok {} ({})", r.executable, r.event); + } else { + println!( + "err {} ({}) — {}", + r.executable, + r.event, + r.error.as_deref().unwrap_or("error") + ); + } + } + } + + if results.iter().any(|r| !r.ok) { + anyhow::bail!("one or more hooks failed"); + } + Ok(()) +} + +fn sample_event(event: HookEventType, provider: &str) -> HookEvent { + let mut e = HookEvent::new(event, provider).with_window("session"); + match event { + HookEventType::QuotaLow => e = e.with_used_percent(85.0), + HookEventType::QuotaReached => e = e.with_used_percent(100.0), + HookEventType::QuotaReset => e = e.with_used_percent(5.0), + HookEventType::ProviderUnavailable => e = e.with_status("unavailable"), + HookEventType::ProviderRecovered => e = e.with_status("ok"), + HookEventType::RefreshFailed => e = e.with_status("refresh_failed"), + } + e +} + +fn parse_event(raw: &str) -> anyhow::Result { + match raw.trim().to_ascii_lowercase().as_str() { + "quota_low" => Ok(HookEventType::QuotaLow), + "quota_reached" => Ok(HookEventType::QuotaReached), + "quota_reset" => Ok(HookEventType::QuotaReset), + "provider_unavailable" => Ok(HookEventType::ProviderUnavailable), + "provider_recovered" => Ok(HookEventType::ProviderRecovered), + "refresh_failed" => Ok(HookEventType::RefreshFailed), + other => anyhow::bail!( + "Unknown event '{other}'. Use one of: quota_low, quota_reached, quota_reset, provider_unavailable, provider_recovered, refresh_failed." + ), + } +} + +fn print_json(value: &T, pretty: bool) -> anyhow::Result<()> { + if pretty { + println!("{}", serde_json::to_string_pretty(value)?); + } else { + println!("{}", serde_json::to_string(value)?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_event_names() { + assert!(matches!( + parse_event("quota_low").unwrap(), + HookEventType::QuotaLow + )); + assert!(parse_event("nope").is_err()); + } + + #[test] + fn sample_quota_low_has_remaining() { + let e = sample_event(HookEventType::QuotaLow, "claude"); + assert!(e.remaining_percent.unwrap() < 20.0); + assert_eq!(e.provider, "claude"); + assert!(e.environment_variables().contains_key("CODEXBAR_PROVIDER")); + } +} diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index a3a00f4a06..1996b38263 100755 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -14,6 +14,7 @@ pub mod config; pub mod cost; pub mod diagnose; pub mod guard; +pub mod hooks; pub mod serve; pub mod sessions; pub mod tty_runner; @@ -145,6 +146,9 @@ pub enum Commands { /// Configuration utilities Config(config::ConfigArgs), + + /// List, enable, disable, or test external hooks + Hooks(hooks::HooksArgs), } impl Cli { diff --git a/rust/src/core/hooks.rs b/rust/src/core/hooks.rs index b660ef24a6..f8f8a2ccd8 100644 --- a/rust/src/core/hooks.rs +++ b/rust/src/core/hooks.rs @@ -242,12 +242,35 @@ impl HooksConfig { serde_json::from_str(content.trim_start_matches('\u{feff}')).unwrap_or_default() } + /// Persist config to the standard hooks.json path (creates parent dirs). + pub fn save(&self) -> Result { + let path = Self::path().ok_or_else(|| "config directory unavailable".to_string())?; + self.save_to(&path)?; + Ok(path) + } + + pub fn save_to(&self, path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create hooks dir: {e}"))?; + } + let body = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?; + std::fs::write(path, body).map_err(|e| format!("write hooks.json: {e}")) + } + pub fn matching_rules(&self, event: &HookEvent) -> Vec<&HookRule> { if !self.enabled || self.events.len() > Self::MAX_RULES { return Vec::new(); } self.events.iter().filter(|rule| rule.matches(event)).collect() } + + /// Rules that match the event ignoring the top-level `enabled` flag (for `hooks test`). + pub fn matching_rules_ignoring_master_switch(&self, event: &HookEvent) -> Vec<&HookRule> { + if self.events.len() > Self::MAX_RULES { + return Vec::new(); + } + self.events.iter().filter(|rule| rule.matches(event)).collect() + } } /// Env keys forwarded from the parent process (never secrets). diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 0f89d082a9..5069461b21 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -513,6 +513,9 @@ locale_keys! { UsageSpendCol30d, UsageSpendColCurrency, UsageSpendColSource, + UsageSpendShare, + UsageSpendShareEmpty, + UsageSpendShareFailed, AgentSessionsTitle, AgentSessionsEnableLabel, AgentSessionsEnableHelper, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 775e8c552f..d9d892c801 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -691,3 +691,6 @@ Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HT PromoteTrayIconLabel = Pin to Taskbar (Windows 11) PromoteTrayIconHelper = Always show the CodexBar icon on the taskbar instead of inside the overflow chevron PromoteTrayIconUnsupportedHint = Not available on this operating system. Drag the icon out of the ^ overflow area in Windows Settings > Taskbar to pin it manually. +UsageSpendShare = Export share card (PNG) +UsageSpendShareEmpty = Nothing to export yet. +UsageSpendShareFailed = Could not create the share card image. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 621ecf4559..9e4342ef3f 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -612,3 +612,6 @@ ShowResetWhenExhaustedHelper = Reemplaza el porcentaje agotado con la cuenta reg PromoteTrayIconLabel = Anclar a la barra de tareas (Windows 11) PromoteTrayIconHelper = Mostrar siempre el icono de CodexBar en la barra de tareas en lugar del área de desbordamiento PromoteTrayIconUnsupportedHint = No disponible en este sistema operativo. Arrastra el icono fuera del área ^ en Configuración de Windows > Barra de tareas para anclarlo manualmente. +UsageSpendShare = Export share card (PNG) +UsageSpendShareEmpty = Nothing to export yet. +UsageSpendShareFailed = Could not create the share card image. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 768e9e48a6..5214214d15 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -691,3 +691,6 @@ ZedApiUrlPlaceholder = https://cloud.zed.dev/client/users/me PromoteTrayIconLabel = タスクバーにピン留め (Windows 11) PromoteTrayIconHelper = CodexBar のアイコンを常にタスクバーに表示します(通知領域のオーバーフローに隠れません) PromoteTrayIconUnsupportedHint = このオペレーティングシステムでは利用できません。Windows の設定 > タスクバー でアイコンを ^ オーバーフローから手動でピン留めしてください。 +UsageSpendShare = Export share card (PNG) +UsageSpendShareEmpty = Nothing to export yet. +UsageSpendShareFailed = Could not create the share card image. diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 1670faf3f1..6e2825b508 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -612,3 +612,6 @@ ShowResetWhenExhaustedHelper = ??? ??? ??? ??? ??????? ???? PromoteTrayIconLabel = 작업 표시줄에 고정 (Windows 11) PromoteTrayIconHelper = CodexBar 아이콘을 항상 작업 표시줄에 표시합니다 (오버플로 영역에 숨겨지지 않음) PromoteTrayIconUnsupportedHint = 이 운영 체제에서는 사용할 수 없습니다. Windows 설정 > 작업 표시줄에서 ^ 오버플로에서 아이콘을 수동으로 꺼내 고정하세요. +UsageSpendShare = Export share card (PNG) +UsageSpendShareEmpty = Nothing to export yet. +UsageSpendShareFailed = Could not create the share card image. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 5572798ebb..e69c4d3e5e 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -691,3 +691,6 @@ Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HT PromoteTrayIconLabel = 固定到任务栏(Windows 11) PromoteTrayIconHelper = 始终将 CodexBar 图标显示在任务栏上,而不是隐藏在溢出区域中 PromoteTrayIconUnsupportedHint = 此操作系统不支持该功能。请在 Windows 设置 > 任务栏 中将图标从 ^ 溢出区域手动拖出固定。 +UsageSpendShare = Export share card (PNG) +UsageSpendShareEmpty = Nothing to export yet. +UsageSpendShareFailed = Could not create the share card image. diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 3f95e423a5..bb98fa0cd7 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -691,3 +691,6 @@ Sub2ApiBaseUrlHelp = Deployment URL for GET /v1/usage. Use HTTPS, or loopback HT PromoteTrayIconLabel = 釘選到工作列(Windows 11) PromoteTrayIconHelper = 一律在工作列顯示 CodexBar 圖示,而非隱藏於溢位區域 PromoteTrayIconUnsupportedHint = 此作業系統不支援此功能。請在 Windows 設定 > 工作列中,從 ^ 溢位區域手動拖出圖示以釘選。 +UsageSpendShare = Export share card (PNG) +UsageSpendShareEmpty = Nothing to export yet. +UsageSpendShareFailed = Could not create the share card image. diff --git a/rust/src/main.rs b/rust/src/main.rs index e7d07d1682..83ce1b1fb9 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -102,6 +102,7 @@ fn dispatch_command(rt: &Runtime, command: Option) -> i32 { Some(Commands::Autostart(args)) => run_unexpected(rt, cli::autostart::run(args)), Some(Commands::Account(args)) => run_unexpected(rt, cli::account::run(args)), Some(Commands::Config(args)) => run_unexpected(rt, cli::config::run(args)), + Some(Commands::Hooks(args)) => run_unexpected(rt, cli::hooks::run(args)), None => missing_subcommand(), } } diff --git a/rust/src/providers/cursor/api.rs b/rust/src/providers/cursor/api.rs index 562c6af881..9bb3b82748 100755 --- a/rust/src/providers/cursor/api.rs +++ b/rust/src/providers/cursor/api.rs @@ -31,6 +31,10 @@ impl CursorApi { } } + pub(super) fn client(&self) -> &reqwest::Client { + &self.client + } + /// Fetch usage information from Cursor API /// Returns (primary, secondary, model_specific, cost, email, plan_type) pub async fn fetch_usage(&self) -> Result { diff --git a/rust/src/providers/cursor/mod.rs b/rust/src/providers/cursor/mod.rs index 4f5e03da79..de554adbc5 100755 --- a/rust/src/providers/cursor/mod.rs +++ b/rust/src/providers/cursor/mod.rs @@ -3,6 +3,7 @@ //! Fetches usage data from Cursor's API using browser cookies mod api; +mod token_cost; use async_trait::async_trait; @@ -38,30 +39,44 @@ impl CursorProvider { } } - async fn fetch_usage_parts( + async fn fetch_web_usage( &self, ctx: &FetchContext, - ) -> Result { - match ctx.source_mode { - // Cli is only ever set by the shell for "no cookie yet"; treat it as - // web so empty-manual users get browser cookie attempt (or AuthRequired) - // instead of "Source mode 'Cli' not supported" (#212). - SourceMode::Auto | SourceMode::Web | SourceMode::Cli => { - self.fetch_web_usage_parts(ctx).await + ) -> Result< + ( + api::CursorUsageResult, + Option, + ), + ProviderError, + > { + let cookie_header = if let Some(cookie_header) = ctx.manual_cookie_header.as_deref() { + cookie_header.to_string() + } else { + crate::providers::browser_cookie_header(&["cursor.com", "cursor.sh"])? + }; + + let usage = self + .api + .fetch_usage_with_cookie_header(&cookie_header) + .await?; + + // Best-effort token-cost page; never fail the main usage fetch. + let token_report = match token_cost::fetch_token_cost_report( + self.api.client(), + &cookie_header, + Some(token_cost::default_since()), + Some(chrono::Utc::now()), + ) + .await + { + Ok(report) => Some(report), + Err(err) => { + tracing::debug!("Cursor token-cost events unavailable: {err}"); + None } - SourceMode::OAuth => Err(ProviderError::UnsupportedSource(ctx.source_mode)), - } - } + }; - async fn fetch_web_usage_parts( - &self, - ctx: &FetchContext, - ) -> Result { - if let Some(cookie_header) = ctx.manual_cookie_header.as_deref() { - self.api.fetch_usage_with_cookie_header(cookie_header).await - } else { - self.api.fetch_usage().await - } + Ok((usage, token_report)) } fn build_usage_snapshot( @@ -70,6 +85,7 @@ impl CursorProvider { model_specific: Option, email: Option, plan_type: Option, + token_report: Option<&token_cost::CursorTokenCostReport>, ) -> UsageSnapshot { let mut usage = UsageSnapshot::new(primary); if let Some(sec) = secondary { @@ -84,10 +100,22 @@ impl CursorProvider { if let Some(plan) = plan_type { usage = usage.with_login_method(plan); } + if let Some(report) = token_report { + for window in report.to_extra_windows() { + usage.extra_rate_windows.push(window); + } + } usage } - fn build_fetch_result(usage: UsageSnapshot, cost: Option) -> ProviderFetchResult { + fn build_fetch_result( + usage: UsageSnapshot, + cost: Option, + token_report: Option<&token_cost::CursorTokenCostReport>, + ) -> ProviderFetchResult { + let cost = token_report + .and_then(|r| r.merge_into_cost(cost.clone())) + .or(cost); let mut result = ProviderFetchResult::new(usage, "web"); if let Some(c) = cost { result = result.with_cost(c); @@ -115,21 +143,37 @@ impl Provider for CursorProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { tracing::debug!("Fetching Cursor usage via web API"); - match self.fetch_usage_parts(ctx).await { - Ok((primary, secondary, model_specific, cost, email, plan_type)) => { - let usage = Self::build_usage_snapshot( - primary, - secondary, - model_specific, - email, - plan_type, - ); - Ok(Self::build_fetch_result(usage, cost)) - } - Err(e) => { - tracing::warn!("Cursor API fetch failed: {}", e); - Err(e) + match ctx.source_mode { + // Cli is only ever set by the shell for "no cookie yet"; treat it as + // web so empty-manual users get browser cookie attempt (or AuthRequired) + // instead of "Source mode 'Cli' not supported" (#212). + SourceMode::Auto | SourceMode::Web | SourceMode::Cli => { + match self.fetch_web_usage(ctx).await { + Ok(( + (primary, secondary, model_specific, cost, email, plan_type), + token_report, + )) => { + let usage = Self::build_usage_snapshot( + primary, + secondary, + model_specific, + email, + plan_type, + token_report.as_ref(), + ); + Ok(Self::build_fetch_result( + usage, + cost, + token_report.as_ref(), + )) + } + Err(e) => { + tracing::warn!("Cursor API fetch failed: {}", e); + Err(e) + } + } } + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(ctx.source_mode)), } } diff --git a/rust/src/providers/cursor/token_cost.rs b/rust/src/providers/cursor/token_cost.rs new file mode 100644 index 0000000000..f925ebe570 --- /dev/null +++ b/rust/src/providers/cursor/token_cost.rs @@ -0,0 +1,375 @@ +//! Cursor dashboard token-cost events (upstream #1745). +//! +//! `POST /api/dashboard/get-filtered-usage-events` — per-model API-rate totals +//! from `tokenUsage.totalCents` and plan-metered totals from `chargedCents`. + +use chrono::{DateTime, Duration, Utc}; +use serde::Deserialize; +use serde_json::json; + +use crate::core::{CostSnapshot, NamedRateWindow, ProviderError, RateWindow}; + +const EVENTS_PATH: &str = "/api/dashboard/get-filtered-usage-events"; +const PAGE_SIZE: usize = 200; +/// Keep fetches bounded for menu-bar refresh latency. +const MAX_PAGES: usize = 5; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageEventsPage { + total_usage_events_count: Option, + #[serde(default)] + usage_events_display: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct UsageEvent { + #[serde(default, deserialize_with = "deserialize_opt_i64", rename = "timestamp")] + timestamp_ms: Option, + model: Option, + token_usage: Option, + #[serde(default, deserialize_with = "deserialize_opt_f64")] + charged_cents: Option, + #[serde(default, deserialize_with = "deserialize_opt_f64")] + cursor_token_fee: Option, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct EventTokenUsage { + #[serde(default, deserialize_with = "deserialize_i64")] + input_tokens: i64, + #[serde(default, deserialize_with = "deserialize_i64")] + output_tokens: i64, + #[serde(default, deserialize_with = "deserialize_i64")] + cache_write_tokens: i64, + #[serde(default, deserialize_with = "deserialize_i64")] + cache_read_tokens: i64, + #[serde(default, deserialize_with = "deserialize_opt_f64")] + total_cents: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CursorTokenCostReport { + /// Sum of vendor list-price cents (API rate) converted to USD. + pub api_rate_usd: f64, + /// Sum of plan-metered chargedCents when every event has a value. + pub metered_usd: Option, + /// Per-model API-rate spend for extra tray windows. + pub by_model_usd: Vec<(String, f64)>, +} + +impl CursorTokenCostReport { + pub fn to_extra_windows(&self) -> Vec { + let mut out = Vec::new(); + let max = self + .by_model_usd + .iter() + .map(|(_, c)| *c) + .fold(0.0_f64, |a, b| a.max(b)) + .max(0.01); + for (model, cost) in self.by_model_usd.iter().take(6) { + let percent = ((cost / max) * 100.0).clamp(0.0, 100.0); + let mut window = RateWindow::new(percent); + window.is_informational = true; + window.reset_description = Some(format!("${cost:.2} API-rate")); + out.push(NamedRateWindow::new( + format!("cursor-model-{}", sanitize_id(model)), + model.clone(), + window, + )); + } + out + } + + pub fn merge_into_cost(&self, base: Option) -> Option { + if self.api_rate_usd <= 0.0 && self.metered_usd.unwrap_or(0.0) <= 0.0 { + return base; + } + let used = self + .metered_usd + .filter(|v| *v > 0.0) + .unwrap_or(self.api_rate_usd); + let period = if self.metered_usd.is_some() { + "Token cost (metered, billing window)" + } else { + "Token cost (API-rate, billing window)" + }; + if let Some(mut base) = base { + // Prefer richer token-cost used when base is plan/on-demand only. + if base.used < used { + base.used = used; + } + base.period = period.into(); + return Some(base); + } + Some(CostSnapshot::new(used, "USD", period)) + } +} + +fn sanitize_id(model: &str) -> String { + model + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + +pub async fn fetch_token_cost_report( + client: &reqwest::Client, + cookie_header: &str, + since: Option>, + until: Option>, +) -> Result { + let mut all = Vec::new(); + let mut expected_total: Option = None; + for page in 1..=MAX_PAGES { + let page_body = fetch_page(client, cookie_header, page, since, until).await?; + if let Some(total) = page_body.total_usage_events_count { + expected_total = Some(total.max(0)); + } + if page_body.usage_events_display.is_empty() { + break; + } + let count = page_body.usage_events_display.len(); + all.extend(page_body.usage_events_display); + if count < PAGE_SIZE { + break; + } + if let Some(expected) = expected_total + && all.len() as i64 >= expected + { + break; + } + } + Ok(summarize_events(&all)) +} + +async fn fetch_page( + client: &reqwest::Client, + cookie_header: &str, + page: usize, + since: Option>, + until: Option>, +) -> Result { + let url = format!("https://cursor.com{EVENTS_PATH}"); + let body = json!({ + "page": page, + "pageSize": PAGE_SIZE, + "startDate": since.map(|d| d.timestamp_millis().to_string()), + "endDate": until.map(|d| d.timestamp_millis().to_string()), + }); + let response = client + .post(&url) + .header("Cookie", cookie_header) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .json(&body) + .timeout(std::time::Duration::from_secs(20)) + .send() + .await?; + if response.status() == 401 || response.status() == 403 { + return Err(ProviderError::AuthRequired); + } + if !response.status().is_success() { + return Err(ProviderError::Other(format!( + "Cursor usage events returned {}", + response.status() + ))); + } + response + .json() + .await + .map_err(|e| ProviderError::Parse(format!("Cursor usage events: {e}"))) +} + +fn summarize_events(events: &[UsageEvent]) -> CursorTokenCostReport { + use std::collections::HashMap; + let mut by_model: HashMap = HashMap::new(); + let mut api_rate_cents = 0.0; + let mut metered_cents = 0.0; + let mut metered_complete = !events.is_empty(); + + for event in events { + let model = event + .model + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("unknown") + .to_string(); + let list_cents = event + .token_usage + .as_ref() + .and_then(|u| u.total_cents) + .filter(|v| v.is_finite() && *v >= 0.0) + .unwrap_or(0.0); + api_rate_cents += list_cents; + *by_model.entry(model).or_insert(0.0) += list_cents; + + match event.charged_cents.filter(|v| v.is_finite() && *v >= 0.0) { + Some(c) => metered_cents += c, + None => { + // cursorTokenFee is sometimes the only metered field. + if let Some(fee) = event.cursor_token_fee.filter(|v| v.is_finite() && *v >= 0.0) { + metered_cents += fee; + } else { + metered_complete = false; + } + } + } + } + + let mut by_model_usd: Vec<(String, f64)> = by_model + .into_iter() + .map(|(m, cents)| (m, cents / 100.0)) + .filter(|(_, usd)| *usd > 0.0) + .collect(); + by_model_usd.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + CursorTokenCostReport { + api_rate_usd: api_rate_cents / 100.0, + metered_usd: if metered_complete && metered_cents > 0.0 { + Some(metered_cents / 100.0) + } else { + None + }, + by_model_usd, + } +} + +/// Default lookback when billing cycle start is unknown: 30 days. +pub fn default_since() -> DateTime { + Utc::now() - Duration::days(30) +} + +fn deserialize_i64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + struct V; + impl<'de> Visitor<'de> for V { + type Value = i64; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("int or string int") + } + fn visit_i64(self, v: i64) -> Result { + Ok(v) + } + fn visit_u64(self, v: u64) -> Result { + Ok(v as i64) + } + fn visit_f64(self, v: f64) -> Result { + Ok(v as i64) + } + fn visit_str(self, v: &str) -> Result { + v.parse().map_err(E::custom) + } + } + deserializer.deserialize_any(V) +} + +fn deserialize_opt_i64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Ok(Some(deserialize_i64(deserializer)?)) +} + +fn deserialize_opt_f64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + struct V; + impl<'de> Visitor<'de> for V { + type Value = Option; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("number or string number") + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_f64(self, v: f64) -> Result { + Ok(if v.is_finite() { Some(v) } else { None }) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Some(v as f64)) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Some(v as f64)) + } + fn visit_str(self, v: &str) -> Result { + Ok(v.parse().ok().filter(|n: &f64| n.is_finite())) + } + } + deserializer.deserialize_any(V) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summarizes_per_model_and_metered() { + let events = vec![ + UsageEvent { + timestamp_ms: Some(1), + model: Some("gpt-5".into()), + token_usage: Some(EventTokenUsage { + input_tokens: 10, + output_tokens: 5, + cache_write_tokens: 0, + cache_read_tokens: 0, + total_cents: Some(25.0), + }), + charged_cents: Some(10.0), + cursor_token_fee: None, + }, + UsageEvent { + timestamp_ms: Some(2), + model: Some("claude-4".into()), + token_usage: Some(EventTokenUsage { + input_tokens: 1, + output_tokens: 1, + cache_write_tokens: 0, + cache_read_tokens: 0, + total_cents: Some(75.0), + }), + charged_cents: Some(40.0), + cursor_token_fee: None, + }, + ]; + let report = summarize_events(&events); + assert!((report.api_rate_usd - 1.0).abs() < 0.001); + assert_eq!(report.metered_usd, Some(0.5)); + assert_eq!(report.by_model_usd[0].0, "claude-4"); + let windows = report.to_extra_windows(); + assert_eq!(windows.len(), 2); + assert!(windows[0].window.is_informational); + } + + #[test] + fn incomplete_metered_is_none() { + let events = vec![UsageEvent { + timestamp_ms: Some(1), + model: Some("gpt-5".into()), + token_usage: Some(EventTokenUsage { + input_tokens: 1, + output_tokens: 1, + cache_write_tokens: 0, + cache_read_tokens: 0, + total_cents: Some(10.0), + }), + charged_cents: None, + cursor_token_fee: None, + }]; + let report = summarize_events(&events); + assert!(report.metered_usd.is_none()); + assert!((report.api_rate_usd - 0.1).abs() < 0.001); + } +}
{t("UsageSpendColProvider")}