From f1075986aa723c4e29199e7907b6c58bab282e01 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 2 Nov 2026 20:55:12 +0700 Subject: [PATCH 1/3] Add pace to serve /usage rows --- CHANGELOG.md | 1 + docs/CLI.md | 2 +- rust/src/cli/serve/data.rs | 84 +++++++++++++++++++++++++++++++----- rust/src/cli/usage.rs | 2 +- rust/src/cli/usage/render.rs | 76 ++++++++++++++++++-------------- 5 files changed, 121 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2cec1cef4..260104e9ab1 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- CLI: `codexbar serve` `/usage` rows now carry the same optional `pace` object as `codexbar usage --json`, so a script polling `serve` can read the reserve or deficit the tray panel shows. It follows the weekly work-days setting and is left out when no window has a pace. Error rows are unchanged. (#1057) - Tray panel: **Settings > Menu > Panel look** switches between Modern and Classic. Classic is the layout from before the redesign (icon-only provider switcher, percent after the title, the full cost dashboard, shortcut hints and icons on every menu row). Drag either vertical edge of the panel to change its width (280 to 560 px at 100 % Panel scale); the width is saved, arrow keys on the focused edge step it, and a double-click or Home restores 310 px. At a high Panel scale the panel never grows wider than the screen's work area. - Tray panel: a new look. The panel sits on a soft tinted surface with larger type. Provider tabs show an icon and a name over a quota underline, wrap to extra rows when many providers are on, and keep the dense icon grid beyond 18. Each quota row is a title, a thicker bar, then the percent used on the left and the reset on the right. Sections are set off by hairlines, and the daily-cost dashboard becomes a collapsible **Cost** section that opens from a two-line summary. The menu is text-only: shortcut hints and the Settings, About and Quit icons are gone, and About no longer carries the version. - Claude: when Claude Code signs out but keeps its login entry with blank tokens, Settings and the tray still list the saved Claude Code accounts, and Switch and Add account work again. Before, every account action failed with "Claude login is missing accessToken" and "No Claude Code accounts found", even though the accounts were saved. diff --git a/docs/CLI.md b/docs/CLI.md index 251f16a1d1f..67f4a0c91f5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -256,7 +256,7 @@ The desktop app can run the same server for you from Settings > Advanced > HTTP | `/metrics` | Prometheus text, only with `--metrics`. Otherwise `404`. | | `/icons/.svg` | Embedded provider icons. No auth. | -`/usage` fetches the selected providers at the same time. Its error rows carry the same `errorKind` and `signInUrl` as `usage --json`. +`/usage` fetches the selected providers at the same time. A successful row carries the same optional `pace` object as `usage --json` (`primary` and `secondary`, each with `stage`, `deltaPercent`, `expectedUsedPercent` and `willLastToReset`, following the weekly work-days setting). It is left out when neither window has a pace. Error rows carry the same `errorKind` and `signInUrl` as `usage --json`. `--refresh-interval` sets how many seconds the dashboard snapshot is cached. Default 60. diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index 67ec6298b7f..07f9af54884 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -28,8 +28,10 @@ use crate::cli::error_kind::{ ERROR_KIND_TIMEOUT, ERROR_KIND_UNKNOWN, error_row, provider_error_row, }; use crate::cli::fetch_context::populate_api_region_from_settings; -use crate::cli::usage::ProviderSelection; -use crate::core::{CostScanOptions, FetchContext, ProviderId, instantiate_provider}; +use crate::cli::usage::{ProviderSelection, usage_pace_json}; +use crate::core::{ + CostScanOptions, FetchContext, ProviderFetchResult, ProviderId, instantiate_provider, +}; use crate::cost_reporting_period::CostReportingPeriod; use crate::cost_scanner::{self, CostScanner}; use crate::settings::Settings; @@ -142,7 +144,11 @@ pub(super) async fn usage_response( |provider_id| { let mut provider_ctx = ctx.clone(); populate_api_region_from_settings(provider_id, &settings, &mut provider_ctx); - fetch_usage_row(provider_id, provider_ctx) + fetch_usage_row( + provider_id, + provider_ctx, + settings.weekly_progress_work_days, + ) }, ) .await; @@ -150,19 +156,38 @@ pub(super) async fn usage_response( } /// One provider's `/usage` row. -async fn fetch_usage_row(provider_id: ProviderId, ctx: FetchContext) -> Value { +async fn fetch_usage_row( + provider_id: ProviderId, + ctx: FetchContext, + pace_work_days: Option, +) -> Value { let provider = instantiate_provider(provider_id); match provider.fetch_usage(&ctx).await { - Ok(result) => json!({ - "provider": provider_id.cli_name(), - "source": result.source_label, - "usage": result.usage, - "cost": result.cost, - }), + Ok(result) => usage_row(provider_id, result, pace_work_days), Err(error) => provider_error_row(provider.as_ref(), &error), } } +/// A successful `/usage` row. `pace` uses the same rules as `usage --json` +/// and is omitted when no lane yields one. +fn usage_row( + provider_id: ProviderId, + result: ProviderFetchResult, + pace_work_days: Option, +) -> Value { + let pace = usage_pace_json(provider_id, &result.usage, pace_work_days); + let mut row = json!({ + "provider": provider_id.cli_name(), + "source": result.source_label, + "usage": result.usage, + "cost": result.cost, + }); + if let Some(pace) = pace { + row["pace"] = pace; + } + row +} + /// `/usage` rows in `providers` order (upstream `serveCollectUsageOutputs`). /// Providers are fetched concurrently; one still running at `deadline` /// becomes a timeout row, and its fetch carries on for a later request to @@ -617,6 +642,45 @@ mod tests { ); } + #[test] + fn usage_rows_carry_the_usage_json_pace() { + use crate::core::{RateWindow, UsageSnapshot}; + + // A 7-day window with 3 days left is 4/7 of the way through. + let reset = chrono::Utc::now() + chrono::Duration::days(3); + let usage = UsageSnapshot::new(RateWindow::new(10.0)).with_secondary( + RateWindow::with_details(10.0, Some(10080), Some(reset), None), + ); + let row = usage_row( + ProviderId::Codex, + ProviderFetchResult::new(usage, "oauth"), + None, + ); + + assert_eq!(row["provider"], "codex"); + assert_eq!(row["source"], "oauth"); + let secondary = &row["pace"]["secondary"]; + let expected = secondary["expectedUsedPercent"].as_f64().expect("pace"); + assert!((expected - 400.0 / 7.0).abs() < 0.1, "{expected}"); + assert!(secondary["stage"].is_string()); + assert!(secondary["willLastToReset"].is_boolean()); + assert!(row["pace"]["primary"].is_null()); + } + + #[test] + fn usage_rows_omit_pace_when_no_lane_has_one() { + use crate::core::{RateWindow, UsageSnapshot}; + + let row = usage_row( + ProviderId::Codex, + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(10.0)), "oauth"), + None, + ); + + assert!(row.get("pace").is_none(), "{row}"); + assert!(row.get("usage").is_some()); + } + #[test] fn daily_array_shape_matches_dashboard_charts_contract() { let daily = daily_json_with_incomplete( diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index dde7f078302..3221d1db9fd 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -15,7 +15,7 @@ use fetch_helpers::{fetch_provider_json_output, fetch_provider_text_output}; use render::render_text; use render::status_json; pub(super) use render::{ - append_status_line, format_percent, render_status_indicator, render_text_error, + append_status_line, format_percent, render_status_indicator, render_text_error, usage_pace_json, }; use render::{is_terminal, print_usage_output}; pub use render::{render_brief_text, render_text_with_status}; diff --git a/rust/src/cli/usage/render.rs b/rust/src/cli/usage/render.rs index 5c2da296bd3..4d974694586 100644 --- a/rust/src/cli/usage/render.rs +++ b/rust/src/cli/usage/render.rs @@ -30,44 +30,15 @@ pub fn render_json_result( status: Option<&StatusInfo>, pace_work_days: Option, ) -> serde_json::Value { - let usage = &result.usage; - let primary_pace = lane_pace(provider_id, &usage.primary, pace_work_days, || { - usage - .primary - .window_minutes - .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) - .then(|| { - UsagePace::weekly( - &usage.primary, - None, - crate::core::SESSION_WINDOW_MINUTES, - pace_work_days, - ) - }) - .flatten() - }) - .map(pace_json); - let secondary_pace = usage - .secondary - .as_ref() - .and_then(|w| { - lane_pace(provider_id, w, pace_work_days, || { - UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080), pace_work_days) - }) - }) - .map(pace_json); - + let pace = usage_pace_json(provider_id, &result.usage, pace_work_days); let mut json_result = serde_json::json!({ "provider": provider_id.cli_name(), "source": result.source_label, "usage": result.usage, "cost": result.cost, }); - if primary_pace.is_some() || secondary_pace.is_some() { - json_result["pace"] = serde_json::json!({ - "primary": primary_pace, - "secondary": secondary_pace, - }); + if let Some(pace) = pace { + json_result["pace"] = pace; } if !result.inventory.is_empty() { @@ -118,6 +89,47 @@ pub fn render_json_result( json_result } +/// The optional `pace` object shared by `usage --json` rows and `serve` +/// `/usage` rows. `None` when neither lane yields a pace. +pub(in crate::cli) fn usage_pace_json( + provider_id: ProviderId, + usage: &UsageSnapshot, + pace_work_days: Option, +) -> Option { + let primary_pace = lane_pace(provider_id, &usage.primary, pace_work_days, || { + usage + .primary + .window_minutes + .is_some_and(|m| m == crate::core::SESSION_WINDOW_MINUTES) + .then(|| { + UsagePace::weekly( + &usage.primary, + None, + crate::core::SESSION_WINDOW_MINUTES, + pace_work_days, + ) + }) + .flatten() + }) + .map(pace_json); + let secondary_pace = usage + .secondary + .as_ref() + .and_then(|w| { + lane_pace(provider_id, w, pace_work_days, || { + UsagePace::weekly(w, None, w.window_minutes.unwrap_or(10080), pace_work_days) + }) + }) + .map(pace_json); + + (primary_pace.is_some() || secondary_pace.is_some()).then(|| { + serde_json::json!({ + "primary": primary_pace, + "secondary": secondary_pace, + }) + }) +} + /// `{level, description}` status object shared by every usage JSON row. pub(super) fn status_json(status: &StatusInfo) -> serde_json::Value { serde_json::json!({ From 72eb947c3a8f4013758733447d873ec7ef92be74 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 2 Nov 2026 21:23:56 +0700 Subject: [PATCH 2/3] Test serve /usage pace with weekly work days --- rust/src/cli/serve/data.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index 07f9af54884..695e0e4087d 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -667,6 +667,41 @@ mod tests { assert!(row["pace"]["primary"].is_null()); } + #[test] + fn usage_row_pace_follows_weekly_work_days() { + use chrono::{Datelike, Duration, Local, TimeZone, Utc}; + + use crate::core::{RateWindow, UsageSnapshot}; + + // A weekly window ending next local Monday 00:00, so Mon-Fri pace is + // ahead of linear pace at any instant inside it. + let today = Local::now().date_naive(); + let next_monday = + today + Duration::days(7 - i64::from(today.weekday().num_days_from_monday())); + let reset = Local + .from_local_datetime(&next_monday.and_hms_opt(0, 0, 0).unwrap()) + .single() + .unwrap() + .with_timezone(&Utc); + if reset - Utc::now() >= Duration::days(7) { + return; // Monday 00:00 exactly or a DST-shortened week. + } + let usage = UsageSnapshot::new(RateWindow::new(10.0)).with_secondary( + RateWindow::with_details(10.0, Some(10080), Some(reset), None), + ); + let expected = |work_days: Option| { + usage_row( + ProviderId::Codex, + ProviderFetchResult::new(usage.clone(), "oauth"), + work_days, + )["pace"]["secondary"]["expectedUsedPercent"] + .as_f64() + .unwrap() + }; + + assert!(expected(Some(5)) > expected(None)); + } + #[test] fn usage_rows_omit_pace_when_no_lane_has_one() { use crate::core::{RateWindow, UsageSnapshot}; From deffed88ce43f08c2a71035acf43c0c18862d2ab Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 2 Nov 2026 21:50:32 +0700 Subject: [PATCH 3/3] Skip DST weeks in the serve work-days pace test --- rust/src/cli/serve/data.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rust/src/cli/serve/data.rs b/rust/src/cli/serve/data.rs index 695e0e4087d..af6ee54882c 100644 --- a/rust/src/cli/serve/data.rs +++ b/rust/src/cli/serve/data.rs @@ -684,7 +684,16 @@ mod tests { .unwrap() .with_timezone(&Utc); if reset - Utc::now() >= Duration::days(7) { - return; // Monday 00:00 exactly or a DST-shortened week. + return; // Monday 00:00 exactly or a DST-lengthened week. + } + // A spring-forward week starts the 10080-minute window on Sunday + // 23:00 local, which linear pace counts and Mon-Fri pace doesn't. + let week_start = (reset - Duration::days(7)).with_timezone(&Local); + let local_monday = (next_monday - Duration::days(7)) + .and_hms_opt(0, 0, 0) + .unwrap(); + if week_start.naive_local() != local_monday { + return; } let usage = UsageSnapshot::new(RateWindow::new(10.0)).with_secondary( RateWindow::with_details(10.0, Some(10080), Some(reset), None),