From 70ff7f928a8cd39f3d06e1b2706ee97e8b05efa2 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:36:56 +0700 Subject: [PATCH 1/3] feat: port upstream 0.51 cost contracts --- rust/src/cli/cost.rs | 105 ++++++++++++++++++++++++- rust/src/codex_workspaces/indexer.rs | 20 +++++ rust/src/codex_workspaces/types.rs | 7 ++ rust/src/providers/opencodego/local.rs | 5 +- 4 files changed, 133 insertions(+), 4 deletions(-) diff --git a/rust/src/cli/cost.rs b/rust/src/cli/cost.rs index 706cc5ab32..2c42ed1b7a 100755 --- a/rust/src/cli/cost.rs +++ b/rust/src/cli/cost.rs @@ -45,6 +45,25 @@ pub struct CostArgs { /// observable effect in the current environment. #[arg(long = "provider-native-only")] pub provider_native_only: bool, + + /// Group text output by Codex local conversation/session. + #[arg(long = "group-by", value_parser = ["session"])] + pub group_by: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CostGroupBy { + None, + Session, +} + +impl CostGroupBy { + fn from_arg(raw: Option<&str>) -> Self { + match raw { + Some("session") => Self::Session, + _ => Self::None, + } + } } /// Run the cost command @@ -56,6 +75,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { }; let providers = ProviderSelection::from_arg(args.provider.as_deref())?; + let group_by = CostGroupBy::from_arg(args.group_by.as_deref()); let use_color = !args.no_color && is_terminal(); let mut scan_options = CostScanOptions::app_driven(); scan_options.include_pi_sessions = !args.provider_native_only; @@ -105,7 +125,7 @@ pub async fn run(args: CostArgs) -> anyhow::Result<()> { match format { OutputFormat::Text => { - print_text_output(&results, use_color, args.days); + print_text_output(&results, use_color, args.days, group_by); } OutputFormat::Json => { print_json_output(&results, args.pretty, args.days)?; @@ -124,7 +144,7 @@ struct CostResult { } /// Print text output -fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { +fn print_text_output(results: &[CostResult], use_color: bool, days: u32, group_by: CostGroupBy) { for (i, result) in results.iter().enumerate() { if use_color { println!( @@ -135,7 +155,11 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { println!("{} Cost (last {} days)", result.display_name, days); } - if !result.supported { + if group_by == CostGroupBy::Session && result.provider == "codex" { + print_codex_session_output(result, days); + } else if group_by == CostGroupBy::Session { + println!(" Session grouping is only available for Codex local conversations"); + } else if !result.supported { println!(" Local cost scanning not available for this provider"); println!(" (Only Codex and Claude have local logs)"); } else if result.summary.sessions_count == 0 { @@ -215,6 +239,69 @@ fn print_text_output(results: &[CostResult], use_color: bool, days: u32) { } } + +fn print_codex_session_output(result: &CostResult, days: u32) { + let index = crate::codex_workspaces::CodexWorkspacesIndex::new(days); + let snapshot = match index.load_snapshot(false, |_| {}) { + Ok(snapshot) => snapshot, + Err(err) => { + println!(" Conversation history unavailable: {err}"); + return; + } + }; + + println!(" Conversations (last {} days):", snapshot.history_days); + if snapshot.source_status.is_partial() { + println!(" Conversation history is incomplete while local indexing catches up."); + } + + if snapshot.sessions.is_empty() { + println!(" —"); + } else { + for session in &snapshot.sessions { + let id = short_session_id(&session.id); + let cost = if session.cost_estimate.unknown_tokens > 0 { + format!("~${:.2} partial", session.cost_estimate.known_usd) + } else { + format!("${:.2}", session.cost_estimate.known_usd) + }; + let model = session.top_model.as_deref().unwrap_or("unknown model"); + println!( + " Session {id}: {cost} · {} tokens · {model}", + format_number(session.totals.total_tokens) + ); + if let Some(activity) = session.latest_activity { + println!( + " {}", + activity.with_timezone(&chrono::Local).format("%b %d, %H:%M") + ); + } + } + } + + if !result.summary.history_coverage_established { + println!(" Coverage: partial (cost history catch-up in progress)"); + } + println!(" Not a subscription bill or plan value · local usage × public API prices"); +} + +fn short_session_id(value: &str) -> String { + let trimmed = value.trim(); + if trimmed.chars().count() <= 12 { + return trimmed.to_string(); + } + let prefix: String = trimmed.chars().take(4).collect(); + let suffix: String = trimmed + .chars() + .rev() + .take(8) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{prefix}...{suffix}") +} + /// Print JSON output fn print_json_output(results: &[CostResult], pretty: bool, days: u32) -> anyhow::Result<()> { let payloads: Vec = results @@ -400,4 +487,16 @@ mod tests { let args = CostArgs::default(); assert!(!args.provider_native_only); } + + #[test] + fn group_by_defaults_none_and_accepts_session() { + assert_eq!(CostGroupBy::from_arg(None), CostGroupBy::None); + assert_eq!(CostGroupBy::from_arg(Some("session")), CostGroupBy::Session); + } + + #[test] + fn short_session_id_is_privacy_conscious() { + assert_eq!(short_session_id("abc"), "abc"); + assert_eq!(short_session_id("1234567890abcdef"), "1234...90abcdef"); + } } diff --git a/rust/src/codex_workspaces/indexer.rs b/rust/src/codex_workspaces/indexer.rs index fe2d1baea4..ebbbb74688 100644 --- a/rust/src/codex_workspaces/indexer.rs +++ b/rust/src/codex_workspaces/indexer.rs @@ -230,6 +230,14 @@ impl CodexWorkspacesIndex { .collect(); daily.sort_by(|a, b| a.day.cmp(&b.day)); + let mut sessions: Vec = + session_buckets.values().map(SessionBucket::to_session_usage).collect(); + sessions.sort_by(|a, b| { + b.latest_activity + .cmp(&a.latest_activity) + .then_with(|| a.id.cmp(&b.id)) + }); + let snapshot = CodexLocalProjectUsageSnapshot { updated_at: Utc::now(), history_days: self.history_days, @@ -237,6 +245,7 @@ impl CodexWorkspacesIndex { indexed_file_count: indexed, skipped_file_count: skipped, total, + sessions, projects, daily, source_status, @@ -851,6 +860,17 @@ mod tests { indexed_file_count: 1, skipped_file_count: 0, total: UsageTotals::from_parts(10, 0, 5), + sessions: vec![SessionUsage { + id: "s1".into(), + project_id: "project-abc".into(), + display_title: "do not leak".into(), + cwd: Some("/Users/me/secret-repo".into()), + started_at: None, + latest_activity: None, + totals: UsageTotals::from_parts(10, 0, 5), + cost_estimate: CostEstimate::default(), + top_model: Some("gpt-5".into()), + }], projects: vec![ProjectUsage { id: "project-abc".into(), display_name: "secret-repo".into(), diff --git a/rust/src/codex_workspaces/types.rs b/rust/src/codex_workspaces/types.rs index f2944d7133..e96af15da3 100644 --- a/rust/src/codex_workspaces/types.rs +++ b/rust/src/codex_workspaces/types.rs @@ -157,6 +157,9 @@ pub struct CodexLocalProjectUsageSnapshot { pub indexed_file_count: u32, pub skipped_file_count: u32, pub total: UsageTotals, + /// All indexed conversations in the selected history window. + #[serde(default)] + pub sessions: Vec, pub projects: Vec, pub daily: Vec, pub source_status: SourceStatus, @@ -166,6 +169,10 @@ impl CodexLocalProjectUsageSnapshot { /// Strip paths/titles for presentation when hide-personal-info is on. /// Does not rewrite the sidecar. pub fn redact_for_privacy(&mut self) { + for session in &mut self.sessions { + session.display_title = "Local Codex chat".to_string(); + session.cwd = None; + } for project in &mut self.projects { if project.id == crate::codex_workspaces::CHATS_PROJECT_ID { project.display_name = crate::codex_workspaces::CHATS_DISPLAY_NAME.to_string(); diff --git a/rust/src/providers/opencodego/local.rs b/rust/src/providers/opencodego/local.rs index 68bc3f5b69..294bbf1d3b 100644 --- a/rust/src/providers/opencodego/local.rs +++ b/rust/src/providers/opencodego/local.rs @@ -111,7 +111,10 @@ impl LocalUsageSnapshot { Some(monthly_reset), None, )); - ProviderFetchResult::new(snap, "local") + // Upstream 0.51 (#2982): local SQLite quota reconstruction is useful + // but it is not server-confirmed authority. Keep that distinction in + // the data contract so CLI/React can present it without guessing. + ProviderFetchResult::new(snap, "local estimate") } } From 4994c4cfae42a8f79164a8bc0f4e8bf238c36ad0 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:43:27 +0700 Subject: [PATCH 2/3] feat: port upstream 0.52 project spend --- .../surfaces/settings/tabs/UsageSpendTab.tsx | 132 +++++++++++++++++- apps/desktop-tauri/src/types/bridge.ts | 2 + rust/src/providers/grok/mod.rs | 51 ++++++- 3 files changed, 180 insertions(+), 5 deletions(-) diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 1fbdc1db9b..21ba5c7621 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useLocale } from "../../../hooks/useLocale"; -import { getSettingsSnapshot, getUsageSpendSummary, updateSettings } from "../../../lib/tauri"; -import type { CostSummaryDisplayStyle, SettingsSnapshot, UsageSpendSummary } from "../../../types/bridge"; +import { getCodexWorkspacesSnapshot, getSettingsSnapshot, getUsageSpendSummary, updateSettings } from "../../../lib/tauri"; +import type { CodexLocalProjectUsageSnapshot, CostSummaryDisplayStyle, SettingsSnapshot, UsageSpendSummary } from "../../../types/bridge"; import type { LocaleKey } from "../../../i18n/keys"; import type { TabProps } from "../settingsTabs"; @@ -124,14 +124,21 @@ export default function UsageSpendTab(_props: TabProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [shareError, setShareError] = useState(null); + const [workspaces, setWorkspaces] = useState(null); + const [showAllProjects, setShowAllProjects] = useState(false); + const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); const tableRef = useRef(null); const load = useCallback(() => { setLoading(true); setError(null); - void getUsageSpendSummary() - .then((data) => { + void Promise.all([ + getUsageSpendSummary(), + getCodexWorkspacesSnapshot({ historyDays: 30 }), + ]) + .then(([data, workspaceData]) => { setSummary(data); + setWorkspaces(workspaceData); setLoading(false); }) .catch((err: unknown) => { @@ -230,10 +237,127 @@ export default function UsageSpendTab(_props: TabProps) { )} + + {!error && workspaces && ( + setShowAllProjects((value) => !value)} + onToggleProject={(id) => { + setExpandedProjects((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }} + /> + )} ); } +function ProjectsPanel({ + snapshot, + showAll, + expanded, + onToggleAll, + onToggleProject, +}: { + snapshot: CodexLocalProjectUsageSnapshot; + showAll: boolean; + expanded: Set; + onToggleAll: () => void; + onToggleProject: (id: string) => void; +}) { + const projects = showAll ? snapshot.projects : snapshot.projects.slice(0, 8); + const partial = snapshot.sourceStatus !== "complete"; + + return ( +
+
+
+

Projects

+

+ Ranked Codex local project spend for the last {snapshot.historyDays} days + {partial ? " · partial history" : ""}. +

+
+ {snapshot.projects.length > 8 && ( + + )} +
+ + {projects.length === 0 ? ( +

No indexed Codex projects yet.

+ ) : ( +
+ {projects.map((project) => { + const isExpanded = expanded.has(project.id); + const partialCost = project.costEstimate.unknownTokens > 0; + return ( +
+ + + {isExpanded && ( +
+ {snapshot.sessions + .filter((session) => session.projectId === project.id) + .map((session) => ( +
+ + {session.displayTitle} + + + {session.costEstimate.unknownTokens > 0 ? "~" : ""} + ${session.costEstimate.knownUsd.toFixed(2)} + +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + function CostSummaryStyleControl({ t }: { t: (key: LocaleKey) => string }) { const [style, setStyle] = useState("compact"); const [loading, setLoading] = useState(true); diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 7d108296c8..6442860de7 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -403,6 +403,8 @@ export interface CodexLocalProjectUsageSnapshot { indexedFileCount: number; skippedFileCount: number; total: CodexWorkspacesUsageTotals; + /** All indexed conversations in the selected history window. */ + sessions: CodexWorkspacesSessionUsage[]; projects: CodexWorkspacesProjectUsage[]; daily: CodexWorkspacesDailyPoint[]; sourceStatus: CodexWorkspacesSourceStatus; diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index 621fd65a54..9013d641a1 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -18,6 +18,7 @@ use crate::core::{ }; const BILLING_ENDPOINT: &str = "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; +const CLI_SETTINGS_ENDPOINT: &str = "https://cli-chat-proxy.grok.com/v1/settings"; pub struct GrokProvider { metadata: ProviderMetadata, @@ -71,15 +72,38 @@ impl GrokProvider { let billing = self .fetch_billing(Some(format!("Bearer {}", credentials.access_token)), None) .await?; + let plan = self + .fetch_cli_subscription_tier(credentials) + .await + .or_else(|| credentials.login_method()); Ok(result_from_billing( billing, "grok-web", credentials.email.clone(), credentials.team_id.clone(), - credentials.login_method(), + plan, )) } + async fn fetch_cli_subscription_tier(&self, credentials: &GrokCredentials) -> Option { + let response = self + .client + .get(CLI_SETTINGS_ENDPOINT) + .timeout(std::time::Duration::from_secs(2)) + .header("Authorization", format!("Bearer {}", credentials.access_token)) + .header("x-xai-token-auth", "xai-grok-cli") + .header("Accept", "application/json") + .header("User-Agent", "CodexBar") + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let value: Value = response.json().await.ok()?; + grok_plan_display_name(value.get("subscription_tier_display").and_then(Value::as_str)) + } + async fn fetch_with_cookie( &self, cookie_header: &str, @@ -301,6 +325,23 @@ impl GrokCredentials { } } +fn grok_plan_display_name(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + return None; + } + let compact: String = trimmed + .to_ascii_lowercase() + .chars() + .filter(|ch| ch.is_ascii_alphabetic()) + .collect(); + Some(match compact.as_str() { + "supergrokheavy" | "heavy" => "SuperGrok Heavy".to_string(), + "supergrok" => "SuperGrok".to_string(), + _ => trimmed.to_string(), + }) +} + fn text_field(value: &Value, key: &str) -> Option { value .get(key) @@ -567,6 +608,14 @@ fn read_varint(data: &[u8], mut i: usize) -> Option<(u64, usize)> { mod tests { use super::*; + #[test] + fn grok_plan_prefers_subscription_tier_display_names() { + assert_eq!(grok_plan_display_name(Some("SuperGrok Heavy")), Some("SuperGrok Heavy".to_string())); + assert_eq!(grok_plan_display_name(Some("heavy")), Some("SuperGrok Heavy".to_string())); + assert_eq!(grok_plan_display_name(Some("SuperGrok")), Some("SuperGrok".to_string())); + assert_eq!(grok_plan_display_name(Some(" custom ")), Some("custom".to_string())); + } + #[test] fn parses_auth_file_prefer_oidc() { let auth = r#"{ From a0cb6af27e0cbaf5c1c2d7f2439ac8a5d37a42e5 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:59:21 +0700 Subject: [PATCH 3/3] feat: complete upstream 0.52 spend parity --- .../src-tauri/src/commands/usage_spend.rs | 76 ++++++++++++++- apps/desktop-tauri/src/i18n/keys.ts | 8 ++ apps/desktop-tauri/src/lib/tauri.ts | 6 +- .../surfaces/settings/tabs/UsageSpendTab.tsx | 92 +++++++++++++++++-- apps/desktop-tauri/src/types/bridge.ts | 10 ++ rust/src/locale.rs | 8 ++ rust/src/locale/en-US.ftl | 8 ++ rust/src/providers/grok/mod.rs | 10 +- 8 files changed, 202 insertions(+), 16 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs index 51e80c9991..fe0a9b6092 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_spend.rs @@ -31,24 +31,42 @@ pub struct UsageSpendRow { #[serde(rename_all = "camelCase")] pub struct UsageSpendSummary { pub rows: Vec, + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageSpendModelRow { + pub provider_id: String, + pub provider_name: String, + pub model_name: String, + pub cost_usd: Option, + pub total_tokens: Option, + pub partial: bool, } #[tauri::command] pub async fn get_usage_spend_summary( state: State<'_, Mutex>, + history_days: Option, ) -> Result { let cached = { let guard = state.lock().map_err(|e| e.to_string())?; guard.provider_cache.clone() }; - tauri::async_runtime::spawn_blocking(move || build_usage_spend_summary(&cached)) + let detail_days = match history_days.unwrap_or(30) { + 1..=7 => 7, + _ => 30, + }; + tauri::async_runtime::spawn_blocking(move || build_usage_spend_summary(&cached, detail_days)) .await .map_err(|e| format!("usage spend worker failed: {e}")) } -fn build_usage_spend_summary(cached: &[ProviderUsageSnapshot]) -> UsageSpendSummary { +fn build_usage_spend_summary(cached: &[ProviderUsageSnapshot], detail_days: u32) -> UsageSpendSummary { let mut rows = Vec::new(); + let mut models = Vec::new(); // F8 (upstream 0.48.0): check codex cache staleness before scanning. When the // debounce has expired, the scan below will rebuild the cache — mark the row @@ -67,7 +85,10 @@ fn build_usage_spend_summary(cached: &[ProviderUsageSnapshot]) -> UsageSpendSumm // Local JSONL scanners for Codex / Claude (primary spend sources). let codex_7 = CostScanner::new(7).scan_codex().total_cost_usd; - let codex_30 = CostScanner::new(30).scan_codex().total_cost_usd; + let codex_30_summary = CostScanner::new(30).scan_codex(); + let codex_30 = codex_30_summary.total_cost_usd; + let codex_detail = if detail_days == 30 { codex_30_summary.clone() } else { CostScanner::new(detail_days).scan_codex() }; + extend_model_rows(&mut models, "codex", "Codex", &codex_detail); rows.push(UsageSpendRow { provider_id: "codex".into(), display_name: "Codex".into(), @@ -80,7 +101,10 @@ fn build_usage_spend_summary(cached: &[ProviderUsageSnapshot]) -> UsageSpendSumm }); let claude_7 = CostScanner::new(7).scan_claude().total_cost_usd; - let claude_30 = CostScanner::new(30).scan_claude().total_cost_usd; + let claude_30_summary = CostScanner::new(30).scan_claude(); + let claude_30 = claude_30_summary.total_cost_usd; + let claude_detail = if detail_days == 30 { claude_30_summary.clone() } else { CostScanner::new(detail_days).scan_claude() }; + extend_model_rows(&mut models, "claude", "Claude", &claude_detail); rows.push(UsageSpendRow { provider_id: "claude".into(), display_name: "Claude".into(), @@ -117,5 +141,47 @@ fn build_usage_spend_summary(cached: &[ProviderUsageSnapshot]) -> UsageSpendSumm }); } - UsageSpendSummary { rows } + models.sort_by(|left, right| match (left.cost_usd, right.cost_usd) { + (Some(a), Some(b)) => b + .partial_cmp(&a) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.model_name.cmp(&right.model_name)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => left.model_name.cmp(&right.model_name), + }); + + UsageSpendSummary { rows, models } +} + +fn extend_model_rows( + rows: &mut Vec, + provider_id: &str, + provider_name: &str, + summary: &codexbar::cost_scanner::CostSummary, +) { + let mut names: std::collections::HashSet = summary.by_model.keys().cloned().collect(); + names.extend(summary.by_model_tokens.keys().cloned()); + names.extend(summary.unknown_models.iter().cloned()); + + for model_name in names { + let partial = summary.unknown_models.contains(&model_name); + let total_tokens = summary + .by_model_tokens + .get(&model_name) + .map(|tokens| tokens.total()); + let cost_usd = if partial { + None + } else { + summary.by_model.get(&model_name).copied() + }; + rows.push(UsageSpendModelRow { + provider_id: provider_id.to_string(), + provider_name: provider_name.to_string(), + model_name, + cost_usd, + total_tokens, + partial, + }); + } } diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 607625c8df..fdc1b04c95 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -347,6 +347,14 @@ export const ALL_LOCALE_KEYS = [ "NetworkProxyTitle", "UsageSpendTitle", "UsageSpendCaption", + "UsageSpendModels", + "UsageSpendProjects", + "UsageSpendShowAll", + "UsageSpendShowLess", + "UsageSpendConversations", + "UsageSpendPartialHistory", + "UsageSpendNoModels", + "UsageSpendNoProjects", "UsageSpendRefresh", "UsageSpendLoading", "UsageSpendRefreshing", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index ea39b554da..0ce7de3633 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -242,8 +242,10 @@ export function getProviderLocalUsageSummary( return invoke("get_provider_local_usage_summary", { providerId }); } -export function getUsageSpendSummary(): Promise { - return invoke("get_usage_spend_summary"); +export function getUsageSpendSummary(options?: { historyDays?: number }): Promise { + return invoke("get_usage_spend_summary", { + historyDays: options?.historyDays ?? null, + }); } export function getCodexWorkspacesSnapshot(options?: { diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx index 21ba5c7621..cfad4af679 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/UsageSpendTab.tsx @@ -121,10 +121,12 @@ function downloadDataUrl(dataUrl: string, filename: string) { export default function UsageSpendTab(_props: TabProps) { const { t } = useLocale(); const [summary, setSummary] = useState(null); + const [selectedDays, setSelectedDays] = useState<7 | 30>(30); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [shareError, setShareError] = useState(null); const [workspaces, setWorkspaces] = useState(null); + const [showAllModels, setShowAllModels] = useState(false); const [showAllProjects, setShowAllProjects] = useState(false); const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); const tableRef = useRef(null); @@ -133,8 +135,8 @@ export default function UsageSpendTab(_props: TabProps) { setLoading(true); setError(null); void Promise.all([ - getUsageSpendSummary(), - getCodexWorkspacesSnapshot({ historyDays: 30 }), + getUsageSpendSummary({ historyDays: selectedDays }), + getCodexWorkspacesSnapshot({ historyDays: selectedDays }), ]) .then(([data, workspaceData]) => { setSummary(data); @@ -145,7 +147,7 @@ export default function UsageSpendTab(_props: TabProps) { setError(err instanceof Error ? err.message : String(err)); setLoading(false); }); - }, []); + }, [selectedDays]); useEffect(() => { load(); @@ -196,6 +198,20 @@ export default function UsageSpendTab(_props: TabProps) { +
+ {([7, 30] as const).map((days) => ( + + ))} +
+ {error &&

{error}

} @@ -238,12 +254,22 @@ export default function UsageSpendTab(_props: TabProps) { )} + {!error && summary && ( + setShowAllModels((value) => !value)} + t={t} + /> + )} + {!error && workspaces && ( setShowAllProjects((value) => !value)} + t={t} onToggleProject={(id) => { setExpandedProjects((current) => { const next = new Set(current); @@ -258,18 +284,68 @@ export default function UsageSpendTab(_props: TabProps) { ); } + +function ModelsPanel({ + models, + showAll, + onToggleAll, + t, +}: { + models: UsageSpendSummary["models"]; + showAll: boolean; + onToggleAll: () => void; + t: (key: LocaleKey) => string; +}) { + const visible = showAll ? models : models.slice(0, 8); + return ( +
+
+

{t("UsageSpendModels")}

+ {models.length > 8 && ( + + )} +
+ {visible.length === 0 ? ( +

{t("UsageSpendNoModels")}

+ ) : ( +
+ {visible.map((model) => ( +
+ + {model.modelName} + + {model.providerName}{model.totalTokens == null ? "" : ` · ${model.totalTokens.toLocaleString()} tokens`} + + + {model.costUsd == null ? "—" : formatUsd(model.costUsd, "USD")}{model.partial ? " · partial" : ""} +
+ ))} +
+ )} +
+ ); +} + function ProjectsPanel({ snapshot, showAll, expanded, onToggleAll, onToggleProject, + t, }: { snapshot: CodexLocalProjectUsageSnapshot; showAll: boolean; expanded: Set; onToggleAll: () => void; onToggleProject: (id: string) => void; + t: (key: LocaleKey) => string; }) { const projects = showAll ? snapshot.projects : snapshot.projects.slice(0, 8); const partial = snapshot.sourceStatus !== "complete"; @@ -278,21 +354,21 @@ function ProjectsPanel({
-

Projects

+

{t("UsageSpendProjects")}

Ranked Codex local project spend for the last {snapshot.historyDays} days - {partial ? " · partial history" : ""}. + {partial ? ` · ${t("UsageSpendPartialHistory")}` : ""}.

{snapshot.projects.length > 8 && ( )}
{projects.length === 0 ? ( -

No indexed Codex projects yet.

+

{t("UsageSpendNoProjects")}

) : (
{projects.map((project) => { @@ -321,7 +397,7 @@ function ProjectsPanel({ {project.displayName} - {project.sessionCount} conversation{project.sessionCount === 1 ? "" : "s"} + {project.sessionCount} {t("UsageSpendConversations")} {project.topModel ? ` · ${project.topModel}` : ""} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 6442860de7..30e5be1460 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -343,6 +343,16 @@ export interface UsageSpendRow { export interface UsageSpendSummary { rows: UsageSpendRow[]; + models: UsageSpendModelRow[]; +} + +export interface UsageSpendModelRow { + providerId: string; + providerName: string; + modelName: string; + costUsd: number | null; + totalTokens: number | null; + partial: boolean; } /** Codex local Workspaces snapshot (get_codex_workspaces_snapshot). */ diff --git a/rust/src/locale.rs b/rust/src/locale.rs index d911f74958..96a281d2c5 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -567,6 +567,14 @@ locale_keys! { NetworkProxyInvalidUrl, UsageSpendTitle, UsageSpendCaption, + UsageSpendModels, + UsageSpendProjects, + UsageSpendShowAll, + UsageSpendShowLess, + UsageSpendConversations, + UsageSpendPartialHistory, + UsageSpendNoModels, + UsageSpendNoProjects, UsageSpendRefresh, UsageSpendLoading, UsageSpendRefreshing, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 4594cd3b72..7e776188ff 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -324,6 +324,14 @@ HooksEnableHelper = Master switch. hooks.json must also set enabled=true and lis HooksConfigPathHint = Config path: %APPDATA%\CodexBar\hooks.json (same folder as settings.json). Events: quota_low, quota_reached, quota_reset. No shell; env is limited to PATH/HOME/USER/TEMP plus CODEXBAR_*. UsageSpendTitle = Usage & Spend UsageSpendCaption = Local estimated cost history for Codex and Claude (JSONL logs), plus period cost snapshots from other providers when available. +UsageSpendModels = Models +UsageSpendProjects = Projects +UsageSpendShowAll = Show all +UsageSpendShowLess = Show less +UsageSpendConversations = conversations +UsageSpendPartialHistory = partial history +UsageSpendNoModels = No model-level history yet. +UsageSpendNoProjects = No indexed Codex projects yet. UsageSpendRefresh = Refresh UsageSpendLoading = Scanning… UsageSpendRefreshing = Refreshing… diff --git a/rust/src/providers/grok/mod.rs b/rust/src/providers/grok/mod.rs index 9013d641a1..016e876f11 100644 --- a/rust/src/providers/grok/mod.rs +++ b/rust/src/providers/grok/mod.rs @@ -111,12 +111,20 @@ impl GrokProvider { let billing = self .fetch_billing(None, Some(cookie_header.to_string())) .await?; + // Upstream 0.52 (#2991): the browser billing response does not carry + // the paid SuperGrok tier. If the local Grok principal is available, + // use its settings endpoint only as identity enrichment, never as a + // replacement for the validated browser usage result. + let plan = match Self::load_credentials() { + Ok(credentials) => self.fetch_cli_subscription_tier(&credentials).await, + Err(_) => None, + }; Ok(result_from_billing( billing, "grok-browser", None, None, - None, + plan, )) }