mirror of
https://github.com/tiennm99/claude-code-usage-bubble.git
synced 2026-10-23 16:16:39 +00:00
A stored session window whose reset time has passed, or whose reset time the provider moved forward by more than a minute, counts as replaced, so usage in the new window climbs from zero. Small reset-time jitter within one window still does not re-alert, and the cooldown is unchanged.
351 lines
12 KiB
Rust
351 lines
12 KiB
Rust
//! Pure builders for the JSON payloads the bubble and panel webviews render.
|
|
//!
|
|
//! Rust owns every displayed string and colour; the webview only lays out
|
|
//! pixels. Field names are camelCase on the wire.
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use serde::Serialize;
|
|
|
|
use crate::core::i18n::{self, LocaleStrings};
|
|
use crate::core::state::ProviderUiState;
|
|
use crate::core::usage_color::{accent_color_for, bar_fill_color};
|
|
use crate::usage::ProviderId;
|
|
|
|
const FIVE_HOURS_SECS: u64 = 5 * 60 * 60;
|
|
const SEVEN_DAYS_SECS: u64 = 7 * 24 * 60 * 60;
|
|
const THIRTY_DAYS_SECS: u64 = 30 * 24 * 60 * 60;
|
|
|
|
/// Placeholder for the head label and weekly countdown before the first poll.
|
|
const PENDING: &str = "…";
|
|
|
|
/// Length of a provider's `(primary, secondary)` usage windows in seconds.
|
|
/// Claude exposes 5h/7d directly; Codex's primary/secondary map to the same
|
|
/// short/long windows in the compact UI.
|
|
pub fn window_durations_secs(provider: ProviderId) -> (u64, u64) {
|
|
match provider {
|
|
ProviderId::Claude | ProviderId::ChatGpt => (FIVE_HOURS_SECS, SEVEN_DAYS_SECS),
|
|
ProviderId::OpenCodeGo => (SEVEN_DAYS_SECS, THIRTY_DAYS_SECS),
|
|
}
|
|
}
|
|
|
|
/// The bubble window's canvas, in physical px.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Canvas {
|
|
pub w: u32,
|
|
pub h: u32,
|
|
pub dpi: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct BubblePayload {
|
|
pub provider: ProviderId,
|
|
pub session_pct: Option<f64>,
|
|
pub weekly_pct: Option<f64>,
|
|
/// `"73%"`, or `"—"` with no value.
|
|
pub session_pct_text: String,
|
|
/// `None` hides the weekly percent.
|
|
pub weekly_pct_text: Option<String>,
|
|
/// Session countdown; `"…"` before the first poll. The UI swaps an empty
|
|
/// or overflowing label for `"5H"`.
|
|
pub head_label: String,
|
|
/// Weekly countdown; `"…"` before the first poll, `""` hides it.
|
|
pub weekly_countdown: String,
|
|
pub session_resets_at_ms: Option<u64>,
|
|
pub weekly_resets_at_ms: Option<u64>,
|
|
pub primary_dur_secs: u64,
|
|
pub secondary_dur_secs: u64,
|
|
pub session_fill: String,
|
|
pub weekly_fill: String,
|
|
pub accent: String,
|
|
pub is_dark: bool,
|
|
pub canvas_w: u32,
|
|
pub canvas_h: u32,
|
|
pub dpi: u32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct PanelPayload {
|
|
pub provider: ProviderId,
|
|
pub label: String,
|
|
pub is_dark: bool,
|
|
pub accent: String,
|
|
pub session_pct: f64,
|
|
pub weekly_pct: f64,
|
|
/// `"73% · 2h"`, `"!"` after an auth failure, `"…"` after a transient
|
|
/// error, `""` before the first poll.
|
|
pub session_text: String,
|
|
pub weekly_text: String,
|
|
pub session_fill: String,
|
|
pub weekly_fill: String,
|
|
}
|
|
|
|
fn unix_ms(t: Option<SystemTime>) -> Option<u64> {
|
|
let ms = t?.duration_since(UNIX_EPOCH).ok()?.as_millis();
|
|
u64::try_from(ms).ok()
|
|
}
|
|
|
|
/// Build the bubble payload. `snapshot` is `None` before the provider's
|
|
/// first poll. After an error the snapshot still holds the last good
|
|
/// windows, so the bubble keeps those values and countdowns; it never shows
|
|
/// the panel's `"!"` or `"…"` markers.
|
|
pub fn bubble_payload(
|
|
provider: ProviderId,
|
|
snapshot: Option<&ProviderUiState>,
|
|
strings: &LocaleStrings,
|
|
is_dark: bool,
|
|
canvas: Canvas,
|
|
now: SystemTime,
|
|
) -> BubblePayload {
|
|
let windows = snapshot.map(|s| s.windows);
|
|
let session_pct = windows.map(|w| w.primary.utilization);
|
|
let weekly_pct = windows.map(|w| w.secondary.utilization);
|
|
let (head_label, weekly_countdown) = match windows {
|
|
Some(w) => (
|
|
i18n::format_countdown_at(w.primary.resets_at, strings, now),
|
|
i18n::format_countdown_at(w.secondary.resets_at, strings, now),
|
|
),
|
|
None => (PENDING.to_string(), PENDING.to_string()),
|
|
};
|
|
let (primary_dur_secs, secondary_dur_secs) = window_durations_secs(provider);
|
|
BubblePayload {
|
|
provider,
|
|
session_pct,
|
|
weekly_pct,
|
|
session_pct_text: i18n::format_pct(session_pct),
|
|
weekly_pct_text: weekly_pct.map(|p| i18n::format_pct(Some(p))),
|
|
head_label,
|
|
weekly_countdown,
|
|
session_resets_at_ms: unix_ms(windows.and_then(|w| w.primary.resets_at)),
|
|
weekly_resets_at_ms: unix_ms(windows.and_then(|w| w.secondary.resets_at)),
|
|
primary_dur_secs,
|
|
secondary_dur_secs,
|
|
session_fill: bar_fill_color(provider, is_dark, session_pct.unwrap_or(0.0)).to_hex(),
|
|
weekly_fill: bar_fill_color(provider, is_dark, weekly_pct.unwrap_or(0.0)).to_hex(),
|
|
accent: accent_color_for(provider, is_dark).to_hex(),
|
|
is_dark,
|
|
canvas_w: canvas.w,
|
|
canvas_h: canvas.h,
|
|
dpi: canvas.dpi,
|
|
}
|
|
}
|
|
|
|
/// Build the details-panel payload. Before the first poll the bars are
|
|
/// empty and the texts blank.
|
|
pub fn panel_payload(
|
|
provider: ProviderId,
|
|
snapshot: Option<&ProviderUiState>,
|
|
strings: &LocaleStrings,
|
|
is_dark: bool,
|
|
) -> PanelPayload {
|
|
let state = snapshot.cloned().unwrap_or_default();
|
|
let session_pct = state.windows.primary.utilization;
|
|
let weekly_pct = state.windows.secondary.utilization;
|
|
PanelPayload {
|
|
provider,
|
|
label: strings.provider_label(provider).to_string(),
|
|
is_dark,
|
|
accent: accent_color_for(provider, is_dark).to_hex(),
|
|
session_pct,
|
|
weekly_pct,
|
|
session_text: state.primary_text,
|
|
weekly_text: state.secondary_text,
|
|
session_fill: bar_fill_color(provider, is_dark, session_pct).to_hex(),
|
|
weekly_fill: bar_fill_color(provider, is_dark, weekly_pct).to_hex(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::time::{Duration, Instant};
|
|
|
|
use super::*;
|
|
use crate::core::i18n::I18n;
|
|
use crate::core::state::{PollOutcome, UsageState};
|
|
use crate::usage::{UsageWindows, Window};
|
|
|
|
const CANVAS: Canvas = Canvas {
|
|
w: 300,
|
|
h: 99,
|
|
dpi: 144,
|
|
};
|
|
|
|
fn now() -> SystemTime {
|
|
UNIX_EPOCH + Duration::from_secs(1_800_000_000)
|
|
}
|
|
|
|
fn strings() -> LocaleStrings {
|
|
I18n::load().strings().clone()
|
|
}
|
|
|
|
fn polled(session: f64, weekly: f64) -> UsageState {
|
|
let mut state = UsageState::default();
|
|
let windows = UsageWindows {
|
|
primary: Window {
|
|
utilization: session,
|
|
resets_at: Some(now() + Duration::from_secs(2 * 3_600 + 10)),
|
|
},
|
|
secondary: Window {
|
|
utilization: weekly,
|
|
resets_at: Some(now() + Duration::from_secs(3 * 86_400)),
|
|
},
|
|
};
|
|
state.apply_results(
|
|
vec![(ProviderId::Claude, PollOutcome::Ok(windows))],
|
|
&strings(),
|
|
Instant::now(),
|
|
SystemTime::now(),
|
|
);
|
|
state
|
|
}
|
|
|
|
#[test]
|
|
fn before_first_poll_shows_placeholders() {
|
|
let p = bubble_payload(ProviderId::ChatGpt, None, &strings(), true, CANVAS, now());
|
|
assert_eq!(p.session_pct, None);
|
|
assert_eq!(p.session_pct_text, "—");
|
|
assert_eq!(p.weekly_pct_text, None);
|
|
assert_eq!(p.head_label, "…");
|
|
assert_eq!(p.weekly_countdown, "…");
|
|
assert_eq!(p.session_resets_at_ms, None);
|
|
assert_eq!(p.session_fill, "#E5E5E5");
|
|
assert_eq!(p.accent, "#E5E5E5");
|
|
assert_eq!((p.canvas_w, p.canvas_h, p.dpi), (300, 99, 144));
|
|
|
|
let panel = panel_payload(ProviderId::ChatGpt, None, &strings(), false);
|
|
assert_eq!(panel.session_text, "");
|
|
assert_eq!(panel.weekly_text, "");
|
|
assert_eq!(panel.session_pct, 0.0);
|
|
assert_eq!(panel.label, "Codex");
|
|
assert_eq!(panel.accent, "#1A1A1A");
|
|
}
|
|
|
|
#[test]
|
|
fn polled_values_format_in_rust() {
|
|
let state = polled(72.5, 96.0);
|
|
let snap = state.snapshots.get(&ProviderId::Claude);
|
|
let p = bubble_payload(ProviderId::Claude, snap, &strings(), true, CANVAS, now());
|
|
assert_eq!(p.session_pct, Some(72.5));
|
|
assert_eq!(p.session_pct_text, "72%");
|
|
assert_eq!(p.weekly_pct_text.as_deref(), Some("96%"));
|
|
assert_eq!(p.head_label, "2h");
|
|
assert_eq!(p.weekly_countdown, "3d");
|
|
assert_eq!(
|
|
p.session_resets_at_ms,
|
|
Some((1_800_000_000 + 2 * 3_600 + 10) * 1_000)
|
|
);
|
|
assert_eq!(
|
|
(p.primary_dur_secs, p.secondary_dur_secs),
|
|
(18_000, 604_800)
|
|
);
|
|
assert_eq!(p.session_fill, "#E0A040");
|
|
assert_eq!(p.weekly_fill, "#A01818");
|
|
assert_eq!(p.accent, "#D97757");
|
|
}
|
|
|
|
#[test]
|
|
fn errors_keep_stale_bubble_values_but_mark_the_panel() {
|
|
let mut state = polled(40.0, 20.0);
|
|
state.apply_results(
|
|
vec![(ProviderId::Claude, PollOutcome::AuthFailed)],
|
|
&strings(),
|
|
Instant::now(),
|
|
SystemTime::now(),
|
|
);
|
|
let snap = state.snapshots.get(&ProviderId::Claude);
|
|
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
|
|
assert_eq!(p.session_pct_text, "40%");
|
|
assert_eq!(p.head_label, "2h");
|
|
assert_eq!(p.weekly_countdown, "3d");
|
|
let json = serde_json::to_string(&p).expect("serialise");
|
|
assert!(!json.contains('!'), "{json}");
|
|
|
|
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
|
|
assert_eq!(panel.session_text, "!");
|
|
assert_eq!(panel.weekly_text, "!");
|
|
assert_eq!(panel.session_pct, 40.0);
|
|
|
|
state.apply_results(
|
|
vec![(ProviderId::Claude, PollOutcome::Failed)],
|
|
&strings(),
|
|
Instant::now(),
|
|
SystemTime::now(),
|
|
);
|
|
let snap = state.snapshots.get(&ProviderId::Claude);
|
|
let panel = panel_payload(ProviderId::Claude, snap, &strings(), false);
|
|
assert_eq!(panel.session_text, "…");
|
|
assert_eq!(panel.weekly_text, "…");
|
|
let p = bubble_payload(ProviderId::Claude, snap, &strings(), false, CANVAS, now());
|
|
assert_eq!(p.head_label, "2h");
|
|
}
|
|
|
|
#[test]
|
|
fn wire_format_is_camel_case_with_nulls() {
|
|
let p = bubble_payload(ProviderId::Claude, None, &strings(), true, CANVAS, now());
|
|
let v = serde_json::to_value(&p).expect("serialise");
|
|
assert_eq!(v["provider"], "claude");
|
|
assert!(v["sessionPct"].is_null());
|
|
assert!(v["weeklyPctText"].is_null());
|
|
assert_eq!(v["sessionPctText"], "—");
|
|
assert_eq!(v["canvasW"], 300);
|
|
assert_eq!(v["isDark"], true);
|
|
let mut keys: Vec<&str> = v
|
|
.as_object()
|
|
.expect("object")
|
|
.keys()
|
|
.map(String::as_str)
|
|
.collect();
|
|
keys.sort_unstable();
|
|
assert_eq!(
|
|
keys,
|
|
[
|
|
"accent",
|
|
"canvasH",
|
|
"canvasW",
|
|
"dpi",
|
|
"headLabel",
|
|
"isDark",
|
|
"primaryDurSecs",
|
|
"provider",
|
|
"secondaryDurSecs",
|
|
"sessionFill",
|
|
"sessionPct",
|
|
"sessionPctText",
|
|
"sessionResetsAtMs",
|
|
"weeklyCountdown",
|
|
"weeklyFill",
|
|
"weeklyPct",
|
|
"weeklyPctText",
|
|
"weeklyResetsAtMs",
|
|
]
|
|
);
|
|
|
|
let panel = serde_json::to_value(panel_payload(ProviderId::Claude, None, &strings(), true))
|
|
.expect("serialise");
|
|
for key in [
|
|
"provider",
|
|
"label",
|
|
"isDark",
|
|
"accent",
|
|
"sessionPct",
|
|
"weeklyPct",
|
|
"sessionText",
|
|
"weeklyText",
|
|
"sessionFill",
|
|
"weeklyFill",
|
|
] {
|
|
assert!(panel.get(key).is_some(), "missing {key}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opencode_go_uses_weekly_and_monthly_windows() {
|
|
assert_eq!(
|
|
window_durations_secs(ProviderId::OpenCodeGo),
|
|
(604_800, 2_592_000)
|
|
);
|
|
}
|
|
}
|