From c40d6a7fa40de08df23683e4278bd4095482a576 Mon Sep 17 00:00:00 2001 From: nytemodeonly Date: Fri, 24 Jul 2026 14:25:07 -0500 Subject: [PATCH 1/2] feat(desktop): show local Codex usage in sidebar Co-authored-by: nytemodeonly Signed-off-by: nytemodeonly --- desktop/src-tauri/src/commands/mod.rs | 2 + .../src-tauri/src/commands/provider_usage.rs | 347 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + .../provider-usage/providerUsageDisplay.d.mts | 11 + .../provider-usage/providerUsageDisplay.mjs | 56 +++ .../providerUsageDisplay.test.mjs | 33 ++ .../ui/SidebarProviderUsageIndicator.tsx | 240 ++++++++++++ .../src/features/settings/ui/SettingsView.tsx | 5 + .../src/features/sidebar/ui/AppSidebar.tsx | 4 + desktop/src/shared/api/tauriProviderUsage.ts | 20 + preview-features.json | 9 + 11 files changed, 728 insertions(+) create mode 100644 desktop/src-tauri/src/commands/provider_usage.rs create mode 100644 desktop/src/features/provider-usage/providerUsageDisplay.d.mts create mode 100644 desktop/src/features/provider-usage/providerUsageDisplay.mjs create mode 100644 desktop/src/features/provider-usage/providerUsageDisplay.test.mjs create mode 100644 desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx create mode 100644 desktop/src/shared/api/tauriProviderUsage.ts diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7e9d916be6..bb7e92756c 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -46,6 +46,7 @@ mod project_git_push; mod project_git_workflow; mod project_repo_paths; mod project_terminal; +mod provider_usage; mod qr_download; mod relay_members; mod relay_reconnect; @@ -96,6 +97,7 @@ pub use project_git_branches::*; pub use project_git_diff::*; pub use project_git_workflow::*; pub use project_terminal::*; +pub use provider_usage::*; pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; diff --git a/desktop/src-tauri/src/commands/provider_usage.rs b/desktop/src-tauri/src/commands/provider_usage.rs new file mode 100644 index 0000000000..234944f9f7 --- /dev/null +++ b/desktop/src-tauri/src/commands/provider_usage.rs @@ -0,0 +1,347 @@ +use serde::Serialize; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const RESPONSE_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CodexProviderUsage { + provider: &'static str, + plan_type: Option, + used_percent: u64, + remaining_percent: u64, + resets_at: Option, + window_duration_minutes: Option, + credit_balance: Option, + reset_credits_available: Option, + lifetime_tokens: Option, + latest_daily_tokens: Option, + latest_daily_date: Option, + fetched_at: u64, +} + +#[tauri::command] +pub async fn get_codex_provider_usage() -> Result { + let codex_path = crate::managed_agents::resolve_command("codex") + .ok_or_else(|| "codex_not_installed".to_string())?; + + tokio::task::spawn_blocking(move || read_codex_provider_usage(&codex_path)) + .await + .map_err(|_| "codex_usage_task_failed".to_string())? +} + +fn read_codex_provider_usage(codex_path: &std::path::Path) -> Result { + let mut command = Command::new(codex_path); + command + .args(["app-server", "--stdio"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // App-server stderr can contain local configuration details. The + // indicator exposes stable error codes instead of forwarding it. + .stderr(Stdio::null()); + if let Some(workdir) = crate::managed_agents::default_agent_workdir() { + command.current_dir(workdir); + } + if let Some(path) = crate::managed_agents::login_shell_path() { + command.env("PATH", path); + } + // The npm/Homebrew Codex launcher can hand off to a native binary. Keep + // the whole launcher tree in one process group so timeout/error cleanup + // also closes descendants that inherited stdout. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + crate::util::configure_no_window(&mut command); + + let mut child = command + .spawn() + .map_err(|_| "codex_app_server_start_failed".to_string())?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| finish_with_error(&mut child, "codex_app_server_stdin_unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| finish_with_error(&mut child, "codex_app_server_stdout_unavailable"))?; + + for message in app_server_requests() { + writeln!(stdin, "{message}") + .map_err(|_| finish_with_error(&mut child, "codex_app_server_write_failed"))?; + } + stdin + .flush() + .map_err(|_| finish_with_error(&mut child, "codex_app_server_write_failed"))?; + + let (sender, receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + if sender.send(line).is_err() { + break; + } + } + }); + + let deadline = Instant::now() + RESPONSE_TIMEOUT; + let mut rate_limits = None; + let mut token_usage = None; + + while rate_limits.is_none() || token_usage.is_none() { + let now = Instant::now(); + if now >= deadline { + stop_child(&mut child); + let _ = reader.join(); + return Err("codex_usage_timeout".to_string()); + } + + match receiver.recv_timeout(deadline.saturating_duration_since(now)) { + Ok(Ok(line)) => { + let Ok(message) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(error_code) = response_error_code(&message) { + stop_child(&mut child); + let _ = reader.join(); + return Err(error_code); + } + match message.get("id").and_then(Value::as_u64) { + Some(2) => rate_limits = message.get("result").cloned(), + Some(3) => token_usage = message.get("result").cloned(), + _ => {} + } + } + Ok(Err(_)) => { + stop_child(&mut child); + let _ = reader.join(); + return Err("codex_app_server_read_failed".to_string()); + } + Err(mpsc::RecvTimeoutError::Timeout) => { + stop_child(&mut child); + let _ = reader.join(); + return Err("codex_usage_timeout".to_string()); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + stop_child(&mut child); + let _ = reader.join(); + return Err("codex_app_server_closed".to_string()); + } + } + } + + stop_child(&mut child); + let _ = reader.join(); + normalize_usage( + rate_limits.as_ref().expect("rate limits checked above"), + token_usage.as_ref().expect("token usage checked above"), + unix_timestamp(), + ) +} + +fn app_server_requests() -> [Value; 4] { + [ + json!({ + "method": "initialize", + "id": 1, + "params": { + "clientInfo": { + "name": "buzz_desktop", + "title": "Buzz Desktop", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { + "experimentalApi": true, + "optOutNotificationMethods": [ + "thread/started", + "item/agentMessage/delta" + ] + } + } + }), + json!({"method": "initialized", "params": {}}), + json!({"method": "account/rateLimits/read", "id": 2, "params": null}), + json!({"method": "account/usage/read", "id": 3, "params": null}), + ] +} + +fn response_error_code(message: &Value) -> Option { + let error = message.get("error")?; + let detail = error + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if detail.contains("auth") || detail.contains("login") { + return Some("codex_not_authenticated".to_string()); + } + if detail.contains("experimental") || detail.contains("method") { + return Some("codex_usage_protocol_unsupported".to_string()); + } + Some("codex_usage_unavailable".to_string()) +} + +fn normalize_usage( + rate_limits_result: &Value, + token_usage_result: &Value, + fetched_at: u64, +) -> Result { + let snapshot = rate_limits_result + .get("rateLimits") + .ok_or_else(|| "codex_usage_invalid_response".to_string())?; + let primary = snapshot + .get("primary") + .filter(|value| !value.is_null()) + .ok_or_else(|| "codex_usage_limit_unavailable".to_string())?; + let used_percent = primary + .get("usedPercent") + .and_then(Value::as_u64) + .ok_or_else(|| "codex_usage_invalid_response".to_string())? + .min(100); + + let latest_bucket = token_usage_result + .get("dailyUsageBuckets") + .and_then(Value::as_array) + .and_then(|buckets| { + buckets.iter().max_by_key(|bucket| { + bucket + .get("startDate") + .and_then(Value::as_str) + .unwrap_or_default() + }) + }); + + Ok(CodexProviderUsage { + provider: "openai", + plan_type: snapshot + .get("planType") + .and_then(Value::as_str) + .map(str::to_owned), + used_percent, + remaining_percent: 100 - used_percent, + resets_at: primary.get("resetsAt").and_then(Value::as_i64), + window_duration_minutes: primary.get("windowDurationMins").and_then(Value::as_u64), + credit_balance: snapshot + .get("credits") + .and_then(|credits| credits.get("balance")) + .and_then(Value::as_str) + .map(str::to_owned), + reset_credits_available: rate_limits_result + .get("rateLimitResetCredits") + .and_then(|credits| credits.get("availableCount")) + .and_then(Value::as_u64), + lifetime_tokens: token_usage_result + .get("summary") + .and_then(|summary| summary.get("lifetimeTokens")) + .and_then(Value::as_u64), + latest_daily_tokens: latest_bucket + .and_then(|bucket| bucket.get("tokens")) + .and_then(Value::as_u64), + latest_daily_date: latest_bucket + .and_then(|bucket| bucket.get("startDate")) + .and_then(Value::as_str) + .map(str::to_owned), + fetched_at, + }) +} + +fn unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn finish_with_error(child: &mut Child, code: &str) -> String { + stop_child(child); + code.to_string() +} + +fn stop_child(child: &mut Child) { + let _ = crate::managed_agents::terminate_process(child.id()); + let _ = child.kill(); + let _ = child.wait(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_live_app_server_shape() { + let rate_limits = json!({ + "rateLimits": { + "primary": { + "usedPercent": 38, + "windowDurationMins": 10080, + "resetsAt": 1785258777 + }, + "credits": { + "balance": "0" + }, + "planType": "pro" + }, + "rateLimitResetCredits": { + "availableCount": 3 + } + }); + let tokens = json!({ + "summary": { + "lifetimeTokens": 13597623776_u64 + }, + "dailyUsageBuckets": [ + {"startDate": "2026-07-23", "tokens": 373817016}, + {"startDate": "2026-07-24", "tokens": 61038450} + ] + }); + + let usage = normalize_usage(&rate_limits, &tokens, 123).unwrap(); + assert_eq!(usage.provider, "openai"); + assert_eq!(usage.plan_type.as_deref(), Some("pro")); + assert_eq!(usage.used_percent, 38); + assert_eq!(usage.remaining_percent, 62); + assert_eq!(usage.resets_at, Some(1785258777)); + assert_eq!(usage.window_duration_minutes, Some(10080)); + assert_eq!(usage.credit_balance.as_deref(), Some("0")); + assert_eq!(usage.reset_credits_available, Some(3)); + assert_eq!(usage.lifetime_tokens, Some(13_597_623_776)); + assert_eq!(usage.latest_daily_tokens, Some(61_038_450)); + assert_eq!(usage.latest_daily_date.as_deref(), Some("2026-07-24")); + assert_eq!(usage.fetched_at, 123); + } + + #[test] + fn clamps_invalid_percentage_and_tolerates_optional_usage() { + let rate_limits = json!({ + "rateLimits": { + "primary": {"usedPercent": 140} + } + }); + let tokens = json!({"summary": {}, "dailyUsageBuckets": null}); + + let usage = normalize_usage(&rate_limits, &tokens, 1).unwrap(); + assert_eq!(usage.used_percent, 100); + assert_eq!(usage.remaining_percent, 0); + assert_eq!(usage.lifetime_tokens, None); + assert_eq!(usage.latest_daily_tokens, None); + } + + #[test] + fn maps_auth_errors_without_forwarding_details() { + let response = json!({ + "id": 2, + "error": { + "code": -32000, + "message": "Login required for alice@example.com" + } + }); + assert_eq!( + response_error_code(&response).as_deref(), + Some("codex_not_authenticated") + ); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3b47866ac4..e124e04ad9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -662,6 +662,7 @@ pub fn run() { transfer_builderlab_community, title_bar_double_click, get_identity, + get_codex_provider_usage, get_nsec, import_identity, persist_current_identity, diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.d.mts b/desktop/src/features/provider-usage/providerUsageDisplay.d.mts new file mode 100644 index 0000000000..a6fb14684b --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.d.mts @@ -0,0 +1,11 @@ +export type ProviderUsageTone = "healthy" | "warning" | "critical"; + +export function providerUsageTone(remainingPercent: number): ProviderUsageTone; + +export function formatTokenCount(value: number | null | undefined): string; + +export function formatUsageReset( + epochSeconds: number | null | undefined, +): string; + +export function providerUsageErrorMessage(error: unknown): string; diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.mjs b/desktop/src/features/provider-usage/providerUsageDisplay.mjs new file mode 100644 index 0000000000..61299f669e --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.mjs @@ -0,0 +1,56 @@ +// @ts-check + +/** + * @param {number} remainingPercent + * @returns {"healthy" | "warning" | "critical"} + */ +export function providerUsageTone(remainingPercent) { + if (remainingPercent < 20) return "critical"; + if (remainingPercent <= 50) return "warning"; + return "healthy"; +} + +/** + * @param {number | null | undefined} value + * @returns {string} + */ +export function formatTokenCount(value) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, + }).format(value); +} + +/** + * @param {number | null | undefined} epochSeconds + * @returns {string} + */ +export function formatUsageReset(epochSeconds) { + if (typeof epochSeconds !== "number" || !Number.isFinite(epochSeconds)) { + return "Reset unavailable"; + } + return new Intl.DateTimeFormat(undefined, { + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(epochSeconds * 1000)); +} + +/** + * @param {unknown} error + * @returns {string} + */ +export function providerUsageErrorMessage(error) { + const code = typeof error === "string" ? error : String(error ?? ""); + if (code.includes("codex_not_installed")) return "Codex is not installed"; + if (code.includes("codex_not_authenticated")) { + return "Sign in with Codex to show usage"; + } + if (code.includes("protocol_unsupported")) { + return "Update Codex to show usage"; + } + return "Usage temporarily unavailable"; +} diff --git a/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs b/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs new file mode 100644 index 0000000000..1c90bb637e --- /dev/null +++ b/desktop/src/features/provider-usage/providerUsageDisplay.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + formatTokenCount, + providerUsageErrorMessage, + providerUsageTone, +} from "./providerUsageDisplay.mjs"; + +test("providerUsageTone follows the remaining-usage thresholds", () => { + assert.equal(providerUsageTone(62), "healthy"); + assert.equal(providerUsageTone(50), "warning"); + assert.equal(providerUsageTone(20), "warning"); + assert.equal(providerUsageTone(19), "critical"); +}); + +test("formatTokenCount stays compact and handles missing values", () => { + assert.equal(formatTokenCount(null), "—"); + assert.match(formatTokenCount(13_597_623_776), /13[.,]?6B/i); +}); + +test("providerUsageErrorMessage never exposes raw app-server details", () => { + assert.equal( + providerUsageErrorMessage( + "codex_not_authenticated: alice@example.com should not render", + ), + "Sign in with Codex to show usage", + ); + assert.equal( + providerUsageErrorMessage("unknown failure with local path /Users/alice"), + "Usage temporarily unavailable", + ); +}); diff --git a/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx b/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx new file mode 100644 index 0000000000..268550ff73 --- /dev/null +++ b/desktop/src/features/provider-usage/ui/SidebarProviderUsageIndicator.tsx @@ -0,0 +1,240 @@ +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +import { getCodexProviderUsage } from "@/shared/api/tauriProviderUsage"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Progress } from "@/shared/ui/progress"; +import { Spinner } from "@/shared/ui/spinner"; +import { cn } from "@/shared/lib/cn"; +import { + formatTokenCount, + formatUsageReset, + providerUsageErrorMessage, + providerUsageTone, +} from "@/features/provider-usage/providerUsageDisplay.mjs"; + +const FIVE_MINUTES = 5 * 60 * 1000; + +const toneClasses = { + healthy: { + stroke: "stroke-emerald-500", + text: "text-emerald-600 dark:text-emerald-400", + progress: "[&>div]:bg-emerald-500", + }, + warning: { + stroke: "stroke-amber-500", + text: "text-amber-600 dark:text-amber-400", + progress: "[&>div]:bg-amber-500", + }, + critical: { + stroke: "stroke-red-500", + text: "text-red-600 dark:text-red-400", + progress: "[&>div]:bg-red-500", + }, +} as const; + +function UsageRing({ + isLoading, + remainingPercent, +}: { + isLoading: boolean; + remainingPercent?: number; +}) { + if (isLoading) { + return