Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
26 changes: 24 additions & 2 deletions src/collector/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,9 +1213,9 @@ fn parse_codex_jsonl(path: &Path) -> Option<CodexJSONLResult> {
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"]
Expand All @@ -1238,9 +1238,11 @@ fn parse_codex_jsonl(path: &Path) -> Option<CodexJSONLResult> {
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);
Expand Down Expand Up @@ -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]
Expand All @@ -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]
Expand Down
42 changes: 37 additions & 5 deletions src/collector/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ struct WindowInfo {
used_percentage: f64,
#[serde(default)]
resets_at: u64,
#[serde(default)]
window_minutes: Option<u64>,
}

/// Read rate limit info from all known Claude config directories.
Expand Down Expand Up @@ -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()),
Expand All @@ -92,10 +102,22 @@ pub fn write_codex_cache(info: &RateLimitInfo) {
}
}

fn window_json(pct: Option<f64>, resets_at: Option<u64>) -> String {
fn window_json(pct: Option<f64>, resets_at: Option<u64>, window_minutes: Option<u64>) -> 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(),
}
}
Expand Down Expand Up @@ -123,8 +145,18 @@ fn read_rate_file(path: &Path, default_source: &str) -> Option<RateLimitInfo> {
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,
})
}
4 changes: 4 additions & 0 deletions src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
];
Expand Down
7 changes: 7 additions & 0 deletions src/model/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,17 @@ pub struct RateLimitInfo {
pub five_hour_pct: Option<f64>,
/// 5-hour window reset timestamp (epoch seconds)
pub five_hour_resets_at: Option<u64>,
/// 5-hour slot duration in minutes, when reported by the source.
pub five_hour_window_minutes: Option<u64>,
/// 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<f64>,
/// 7-day window reset timestamp (epoch seconds)
pub seven_day_resets_at: Option<u64>,
/// Long-window slot duration in minutes, when reported by the source.
pub seven_day_window_minutes: Option<u64>,
/// When this data was last updated
pub updated_at: Option<u64>,
}
Expand Down
20 changes: 18 additions & 2 deletions src/ui/quota.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -235,6 +235,22 @@ fn draw_source_column(
f.render_widget(Paragraph::new(lines), area);
}

fn format_window_label(window_minutes: Option<u64>, 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
Expand Down
Loading