diff --git a/src/app.rs b/src/app.rs index 23783a8..158a738 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1151,8 +1151,10 @@ mod tests { source: source.to_string(), five_hour_pct: Some(pct), five_hour_resets_at: None, + five_hour_window_minutes: Some(300), seven_day_pct: None, seven_day_resets_at: None, + seven_day_window_minutes: None, updated_at: None, } } diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 3c7b4c0..688e5f2 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1213,9 +1213,9 @@ fn parse_codex_jsonl(path: &Path) -> Option { if let Some(cw) = info["model_context_window"].as_u64() { result.context_window = cw; } - // Rate limits: assign to 5h/7d slots based on window_minutes. + // Rate limits: assign to short/long slots based on window_minutes. // Plus plans: primary=5h(300min), secondary=7d(10080min). - // Free plans: primary=7d(10080min), secondary=null. + // Free plans: primary can be a longer window, such as 30d(43200min). let rl = &payload["rate_limits"]; if rl.is_object() && is_account_level_codex_rate_limit(rl) { let event_secs = val["timestamp"] @@ -1238,9 +1238,11 @@ fn parse_codex_jsonl(path: &Path) -> Option { if mins <= 300 { info.five_hour_pct = pct; info.five_hour_resets_at = resets; + info.five_hour_window_minutes = Some(mins); } else { info.seven_day_pct = pct; info.seven_day_resets_at = resets; + info.seven_day_window_minutes = Some(mins); } } result.rate_limit = Some(info); @@ -1843,7 +1845,26 @@ mod tests { let result = parse_codex_jsonl(file.path()).unwrap(); let rl = result.rate_limit.expect("rate_limit should be Some"); assert_eq!(rl.five_hour_pct, Some(9.0)); + assert_eq!(rl.five_hour_window_minutes, Some(300)); assert_eq!(rl.seven_day_pct, Some(14.0)); + assert_eq!(rl.seven_day_window_minutes, Some(10_080)); + } + + #[test] + fn test_parse_codex_free_rate_limit_uses_thirty_day_window() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-06-17T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"output_tokens":1},"last_token_usage":{"input_tokens":1,"output_tokens":1}},"rate_limits":{"limit_id":"codex","primary":{"used_percent":48.0,"window_minutes":43200,"resets_at":1780000000},"secondary":null,"plan_type":"free"}}}"#, + ], + ); + let result = parse_codex_jsonl(file.path()).unwrap(); + let rl = result.rate_limit.expect("rate_limit should be Some"); + assert_eq!(rl.five_hour_pct, None); + assert_eq!(rl.seven_day_pct, Some(48.0)); + assert_eq!(rl.seven_day_window_minutes, Some(43_200)); } #[test] @@ -1862,6 +1883,7 @@ mod tests { let rl = result.rate_limit.expect("account rate_limit should remain"); assert_eq!(rl.five_hour_pct, Some(25.0)); assert_eq!(rl.seven_day_pct, Some(4.0)); + assert_eq!(rl.seven_day_window_minutes, Some(10_080)); } #[test] diff --git a/src/collector/rate_limit.rs b/src/collector/rate_limit.rs index c177fda..e9722d5 100644 --- a/src/collector/rate_limit.rs +++ b/src/collector/rate_limit.rs @@ -26,6 +26,8 @@ struct WindowInfo { used_percentage: f64, #[serde(default)] resets_at: u64, + #[serde(default)] + window_minutes: Option, } /// Read rate limit info from all known Claude config directories. @@ -78,8 +80,16 @@ pub fn write_codex_cache(info: &RateLimitInfo) { let json = format!( r#"{{"source":"codex","five_hour":{},"seven_day":{},"updated_at":{}}}"#, - window_json(info.five_hour_pct, info.five_hour_resets_at), - window_json(info.seven_day_pct, info.seven_day_resets_at), + window_json( + info.five_hour_pct, + info.five_hour_resets_at, + info.five_hour_window_minutes + ), + window_json( + info.seven_day_pct, + info.seven_day_resets_at, + info.seven_day_window_minutes + ), info.updated_at .map(|v| v.to_string()) .unwrap_or_else(|| "null".to_string()), @@ -92,10 +102,22 @@ pub fn write_codex_cache(info: &RateLimitInfo) { } } -fn window_json(pct: Option, resets_at: Option) -> String { +fn window_json(pct: Option, resets_at: Option, window_minutes: Option) -> String { match (pct, resets_at) { - (Some(p), Some(r)) => format!(r#"{{"used_percentage":{},"resets_at":{}}}"#, p, r), - (Some(p), None) => format!(r#"{{"used_percentage":{},"resets_at":0}}"#, p), + (Some(p), Some(r)) => match window_minutes { + Some(m) => format!( + r#"{{"used_percentage":{},"resets_at":{},"window_minutes":{}}}"#, + p, r, m + ), + None => format!(r#"{{"used_percentage":{},"resets_at":{}}}"#, p, r), + }, + (Some(p), None) => match window_minutes { + Some(m) => format!( + r#"{{"used_percentage":{},"resets_at":0,"window_minutes":{}}}"#, + p, m + ), + None => format!(r#"{{"used_percentage":{},"resets_at":0}}"#, p), + }, _ => "null".to_string(), } } @@ -123,8 +145,18 @@ fn read_rate_file(path: &Path, default_source: &str) -> Option { source, five_hour_pct: file.five_hour.as_ref().map(|w| w.used_percentage), five_hour_resets_at: file.five_hour.as_ref().map(|w| w.resets_at), + five_hour_window_minutes: file + .five_hour + .as_ref() + .and_then(|w| w.window_minutes) + .or(file.five_hour.as_ref().map(|_| 300)), seven_day_pct: file.seven_day.as_ref().map(|w| w.used_percentage), seven_day_resets_at: file.seven_day.as_ref().map(|w| w.resets_at), + seven_day_window_minutes: file + .seven_day + .as_ref() + .and_then(|w| w.window_minutes) + .or(file.seven_day.as_ref().map(|_| 10_080)), updated_at: file.updated_at, }) } diff --git a/src/demo.rs b/src/demo.rs index 06df788..3e28358 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -539,16 +539,20 @@ pub fn populate_demo(app: &mut App) { source: "claude".into(), five_hour_pct: Some(35.0), five_hour_resets_at: Some(now_secs() + 3 * 3600), + five_hour_window_minutes: Some(300), seven_day_pct: Some(12.0), seven_day_resets_at: Some(now_secs() + 5 * 24 * 3600), + seven_day_window_minutes: Some(10_080), updated_at: Some(now_secs() - 10), }, RateLimitInfo { source: "codex".into(), five_hour_pct: Some(9.0), five_hour_resets_at: Some(now_secs() + 4 * 3600), + five_hour_window_minutes: Some(300), seven_day_pct: Some(14.0), seven_day_resets_at: Some(now_secs() + 6 * 24 * 3600), + seven_day_window_minutes: Some(10_080), updated_at: Some(now_secs() - 5), }, ]; diff --git a/src/model/session.rs b/src/model/session.rs index 2bc5f24..cc3a5bf 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -41,10 +41,17 @@ pub struct RateLimitInfo { pub five_hour_pct: Option, /// 5-hour window reset timestamp (epoch seconds) pub five_hour_resets_at: Option, + /// 5-hour slot duration in minutes, when reported by the source. + pub five_hour_window_minutes: Option, /// 7-day window usage percentage (0-100) + /// + /// Historical field name kept for compatibility; Codex may use this slot + /// for a longer account-level window such as 30 days. pub seven_day_pct: Option, /// 7-day window reset timestamp (epoch seconds) pub seven_day_resets_at: Option, + /// Long-window slot duration in minutes, when reported by the source. + pub seven_day_window_minutes: Option, /// When this data was last updated pub updated_at: Option, } diff --git a/src/ui/quota.rs b/src/ui/quota.rs index 72b19e0..1c65813 100644 --- a/src/ui/quota.rs +++ b/src/ui/quota.rs @@ -175,7 +175,7 @@ fn draw_source_column( String::new() }; let c = grad_at(cpu_grad, used_pct); - let label_5h = t("quota.5h"); + let label_5h = format_window_label(rl.five_hour_window_minutes, t("quota.5h")); let mut s = vec![styled_label( format!(" {}", label_5h).as_str(), theme.graph_text, @@ -208,7 +208,7 @@ fn draw_source_column( String::new() }; let c = grad_at(cpu_grad, used_pct); - let label_7d = t("quota.7d"); + let label_7d = format_window_label(rl.seven_day_window_minutes, t("quota.7d")); let mut s = vec![styled_label( format!(" {}", label_7d).as_str(), theme.graph_text, @@ -235,6 +235,22 @@ fn draw_source_column( f.render_widget(Paragraph::new(lines), area); } +fn format_window_label(window_minutes: Option, fallback: String) -> String { + let Some(minutes) = window_minutes else { + return fallback; + }; + + if minutes == 0 { + fallback + } else if minutes % (24 * 60) == 0 { + format!("{}{}", minutes / (24 * 60), t("time.d")) + } else if minutes % 60 == 0 { + format!("{}{}", minutes / 60, t("time.h")) + } else { + format!("{}{}", minutes, t("time.m")) + } +} + /// Format a reset timestamp as a human countdown labeled "in X" so the /// row reads as a time-until-reset. Returns an empty string when the /// reset is already in the past — the actual next reset depends on the