feat(rate-limits): track API reset timers across core and TUI

This commit is contained in:
James Peter
2025-09-25 15:56:01 +10:00
parent 66a88d1aef
commit 417c1867fa
8 changed files with 132 additions and 240 deletions
+19 -4
View File
@@ -72,8 +72,10 @@ struct RateLimitInfo {
snapshot: Option<RateLimitSnapshotEvent>,
#[serde(default)]
observed_at: Option<DateTime<Utc>>,
#[serde(default, alias = "next_reset_at")]
primary_next_reset_at: Option<DateTime<Utc>>,
#[serde(default)]
next_reset_at: Option<DateTime<Utc>>,
secondary_next_reset_at: Option<DateTime<Utc>>,
#[serde(default)]
last_usage_limit_hit_at: Option<DateTime<Utc>>,
}
@@ -141,7 +143,8 @@ pub struct StoredRateLimitSnapshot {
pub plan: Option<String>,
pub snapshot: Option<RateLimitSnapshotEvent>,
pub observed_at: Option<DateTime<Utc>>,
pub next_reset_at: Option<DateTime<Utc>>,
pub primary_next_reset_at: Option<DateTime<Utc>>,
pub secondary_next_reset_at: Option<DateTime<Utc>>,
pub last_usage_limit_hit_at: Option<DateTime<Utc>>,
}
@@ -252,6 +255,12 @@ pub fn record_rate_limit_snapshot(
let mut info = data.rate_limit.take().unwrap_or_default();
info.snapshot = Some(snapshot.clone());
info.observed_at = Some(observed_at);
info.primary_next_reset_at = snapshot
.primary_reset_after_seconds
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
info.secondary_next_reset_at = snapshot
.secondary_reset_after_seconds
.map(|seconds| observed_at + Duration::seconds(seconds as i64));
data.rate_limit = Some(info);
})
}
@@ -294,12 +303,17 @@ pub fn list_rate_limit_snapshots(
Err(_) => continue,
};
let rate = data.rate_limit.unwrap_or_default();
let primary_next_reset_at = rate.primary_next_reset_at;
let secondary_next_reset_at = rate
.secondary_next_reset_at
.or(rate.primary_next_reset_at);
results.push(StoredRateLimitSnapshot {
account_id: data.account_id,
plan: data.plan,
snapshot: rate.snapshot,
observed_at: rate.observed_at,
next_reset_at: rate.next_reset_at,
primary_next_reset_at,
secondary_next_reset_at,
last_usage_limit_hit_at: rate.last_usage_limit_hit_at,
});
}
@@ -330,7 +344,8 @@ pub fn record_usage_limit_hint(
info.last_usage_limit_hit_at = Some(observed_at);
if let Some(seconds) = resets_in_seconds {
let reset_at = observed_at + Duration::seconds(seconds as i64);
info.next_reset_at = Some(reset_at);
info.primary_next_reset_at = Some(reset_at);
info.secondary_next_reset_at = Some(reset_at);
}
data.rate_limit = Some(info);
})
+6
View File
@@ -703,6 +703,10 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
parse_header_f64(headers, "x-codex-primary-over-secondary-limit-percent")?;
let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?;
let secondary_window_minutes = parse_header_u64(headers, "x-codex-secondary-window-minutes")?;
let primary_reset_after_seconds =
parse_header_u64(headers, "x-codex-primary-reset-after-seconds");
let secondary_reset_after_seconds =
parse_header_u64(headers, "x-codex-secondary-reset-after-seconds");
Some(RateLimitSnapshotEvent {
primary_used_percent,
@@ -710,6 +714,8 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshotEve
primary_to_secondary_ratio_percent,
primary_window_minutes,
secondary_window_minutes,
primary_reset_after_seconds,
secondary_reset_after_seconds,
})
}
+1 -7
View File
@@ -4754,13 +4754,7 @@ async fn try_run_turn(
}
ResponseEvent::RateLimits(snapshot) => {
let mut state = sess.state.lock().unwrap();
state.latest_rate_limits = Some(RateLimitSnapshotEvent {
primary_used_percent: snapshot.primary_used_percent,
secondary_used_percent: snapshot.secondary_used_percent,
primary_to_secondary_ratio_percent: snapshot.primary_to_secondary_ratio_percent,
primary_window_minutes: snapshot.primary_window_minutes,
secondary_window_minutes: snapshot.secondary_window_minutes,
});
state.latest_rate_limits = Some(snapshot.clone());
if let Some(ctx) = account_usage_context(sess) {
let usage_home = ctx.codex_home.clone();
let usage_account = ctx.account_id.clone();
+18 -2
View File
@@ -596,12 +596,12 @@ fn rate_limit_snapshot_to_protocol(
let primary = codex_protocol::protocol::RateLimitWindow {
used_percent: snapshot.primary_used_percent,
window_minutes: Some(snapshot.primary_window_minutes),
resets_in_seconds: None,
resets_in_seconds: snapshot.primary_reset_after_seconds,
};
let secondary = codex_protocol::protocol::RateLimitWindow {
used_percent: snapshot.secondary_used_percent,
window_minutes: Some(snapshot.secondary_window_minutes),
resets_in_seconds: None,
resets_in_seconds: snapshot.secondary_reset_after_seconds,
};
codex_protocol::protocol::RateLimitSnapshot {
primary: Some(primary),
@@ -629,11 +629,19 @@ fn rate_limit_snapshot_from_protocol(
.as_ref()
.and_then(|window| window.window_minutes)
.unwrap_or(0);
let primary_reset_after_seconds = snapshot
.primary
.as_ref()
.and_then(|window| window.resets_in_seconds);
let secondary_window_minutes = snapshot
.secondary
.as_ref()
.and_then(|window| window.window_minutes)
.unwrap_or(0);
let secondary_reset_after_seconds = snapshot
.secondary
.as_ref()
.and_then(|window| window.resets_in_seconds);
let ratio_percent = match (primary_window_minutes, secondary_window_minutes) {
(0, _) | (_, 0) => f64::NAN,
@@ -649,6 +657,8 @@ fn rate_limit_snapshot_from_protocol(
primary_to_secondary_ratio_percent: ratio_percent,
primary_window_minutes,
secondary_window_minutes,
primary_reset_after_seconds,
secondary_reset_after_seconds,
}
}
@@ -983,6 +993,12 @@ pub struct RateLimitSnapshotEvent {
pub primary_window_minutes: u64,
/// Rolling window duration for the secondary limit, in minutes.
pub secondary_window_minutes: u64,
/// Seconds until the primary window resets, if reported by the API.
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_reset_after_seconds: Option<u64>,
/// Seconds until the secondary window resets, if reported by the API.
#[serde(skip_serializing_if = "Option::is_none")]
pub secondary_reset_after_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
+39 -181
View File
@@ -2,7 +2,7 @@ use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::rc::{Rc, Weak};
use std::sync::Arc;
use std::sync::mpsc::Sender;
@@ -123,8 +123,6 @@ use ratatui_image::picker::Picker;
use std::cell::Cell;
use std::cell::RefCell;
use std::sync::mpsc;
use std::fs::OpenOptions;
use std::io::{self, Read, Seek, SeekFrom, Write};
use tokio::sync::mpsc::UnboundedSender;
fn history_cell_logging_enabled() -> bool {
@@ -142,7 +140,6 @@ fn history_cell_logging_enabled() -> bool {
use serde_json::Value as JsonValue;
use tokio::sync::mpsc::unbounded_channel;
use tracing::info;
use fs2::FileExt;
// use image::GenericImageView;
use crate::app_event::{
@@ -274,7 +271,6 @@ struct RunningCommand {
const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [50.0, 75.0, 90.0];
const RATE_LIMIT_REFRESH_INTERVAL: chrono::Duration = chrono::Duration::minutes(10);
const RATE_LIMIT_RESET_FILE: &str = "state/rate_limit_resets.json";
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
#[derive(Default)]
@@ -379,8 +375,8 @@ pub(crate) struct ChatWidget<'a> {
rate_limit_warnings: RateLimitWarningState,
rate_limit_fetch_inflight: bool,
rate_limit_last_fetch_at: Option<DateTime<Utc>>,
rate_limit_last_primary_reset_at: Option<chrono::DateTime<chrono::Utc>>,
rate_limit_last_weekly_reset_at: Option<chrono::DateTime<chrono::Utc>>,
rate_limit_primary_next_reset_at: Option<DateTime<Utc>>,
rate_limit_secondary_next_reset_at: Option<DateTime<Utc>>,
content_buffer: String,
// Buffer for streaming assistant answer text; we do not surface partial
// We wait for the final AgentMessage event and then emit the full text
@@ -2540,12 +2536,6 @@ impl ChatWidget<'_> {
// Initialize image protocol for rendering screenshots
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
let RateLimitResetCache {
primary_last_reset,
weekly_last_reset,
} = reset_cache;
let mut new_widget = Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
@@ -2572,8 +2562,8 @@ impl ChatWidget<'_> {
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_fetch_inflight: false,
rate_limit_last_fetch_at: None,
rate_limit_last_primary_reset_at: primary_last_reset,
rate_limit_last_weekly_reset_at: weekly_last_reset,
rate_limit_primary_next_reset_at: None,
rate_limit_secondary_next_reset_at: None,
content_buffer: String::new(),
last_assistant_message: None,
exec: ExecState {
@@ -2694,6 +2684,14 @@ impl ChatWidget<'_> {
system_cell_by_id: HashMap::new(),
standard_terminal_mode: !config.tui.alternate_screen,
};
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
new_widget.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
new_widget.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
}
}
}
// Seed footer access indicator based on current config
new_widget.apply_access_mode_indicator_from_config();
// Insert the welcome cell as top-of-first-request so future model output
@@ -2774,12 +2772,6 @@ impl ChatWidget<'_> {
// Basic widget state mirrors `new`
let history_cells: Vec<Box<dyn HistoryCell>> = Vec::new();
let reset_cache = load_rate_limit_reset_cache(&config.codex_home);
let RateLimitResetCache {
primary_last_reset,
weekly_last_reset,
} = reset_cache;
let mut w = Self {
app_event_tx: app_event_tx.clone(),
codex_op_tx,
@@ -2803,8 +2795,8 @@ impl ChatWidget<'_> {
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_fetch_inflight: false,
rate_limit_last_fetch_at: None,
rate_limit_last_primary_reset_at: primary_last_reset,
rate_limit_last_weekly_reset_at: weekly_last_reset,
rate_limit_primary_next_reset_at: None,
rate_limit_secondary_next_reset_at: None,
content_buffer: String::new(),
last_assistant_message: None,
exec: ExecState {
@@ -2923,6 +2915,14 @@ impl ChatWidget<'_> {
synthetic_system_req: None,
system_cell_by_id: HashMap::new(),
};
if let Ok(Some(active_id)) = auth_accounts::get_active_account_id(&config.codex_home) {
if let Ok(records) = account_usage::list_rate_limit_snapshots(&config.codex_home) {
if let Some(record) = records.into_iter().find(|r| r.account_id == active_id) {
w.rate_limit_primary_next_reset_at = record.primary_next_reset_at;
w.rate_limit_secondary_next_reset_at = record.secondary_next_reset_at;
}
}
}
w.set_standard_terminal_mode(!config.tui.alternate_screen);
if show_welcome {
w.history_push_top_next_req(history_cell::new_animated_welcome());
@@ -3490,17 +3490,15 @@ impl ChatWidget<'_> {
match record {
Some(record) => {
if let Some(snapshot) = record.snapshot.clone() {
let view_snapshot = RateLimitSnapshotEvent {
primary_used_percent: snapshot.primary_used_percent,
secondary_used_percent: snapshot.secondary_used_percent,
primary_to_secondary_ratio_percent:
snapshot.primary_to_secondary_ratio_percent,
primary_window_minutes: snapshot.primary_window_minutes,
secondary_window_minutes: snapshot.secondary_window_minutes,
let view_snapshot = snapshot.clone();
let view_reset = RateLimitResetInfo {
primary_next_reset: record.primary_next_reset_at,
secondary_next_reset: record.secondary_next_reset_at,
..RateLimitResetInfo::default()
};
let view = build_limits_view(
&view_snapshot,
RateLimitResetInfo::default(),
view_reset,
DEFAULT_GRID_CONFIG,
);
let header = Self::account_header_lines(
@@ -5867,8 +5865,7 @@ impl ChatWidget<'_> {
self.last_token_usage = info.last_token_usage.clone();
}
if let Some(snapshot) = event.rate_limits {
let previous_snapshot = self.rate_limit_snapshot.clone();
self.update_rate_limit_resets(previous_snapshot.as_ref(), &snapshot);
self.update_rate_limit_resets(&snapshot);
let warnings = self
.rate_limit_warnings
.take_warnings(snapshot.secondary_used_percent, snapshot.primary_used_percent);
@@ -7211,8 +7208,8 @@ impl ChatWidget<'_> {
let context_tokens_used = context_window.map(|_| self.last_token_usage.tokens_in_context_window());
RateLimitResetInfo {
primary_last_reset: self.rate_limit_last_primary_reset_at,
weekly_last_reset: self.rate_limit_last_weekly_reset_at,
primary_next_reset: self.rate_limit_primary_next_reset_at,
secondary_next_reset: self.rate_limit_secondary_next_reset_at,
session_tokens_used,
auto_compact_limit,
overflow_auto_compact: true,
@@ -7221,65 +7218,14 @@ impl ChatWidget<'_> {
}
}
fn update_rate_limit_resets(
&mut self,
previous: Option<&RateLimitSnapshotEvent>,
current: &RateLimitSnapshotEvent,
) {
fn update_rate_limit_resets(&mut self, current: &RateLimitSnapshotEvent) {
let now = Utc::now();
let mut changed_primary = false;
let mut changed_weekly = false;
if let Some(prev) = previous {
if current.primary_used_percent + 1.0 < prev.primary_used_percent {
if self
.rate_limit_last_primary_reset_at
.map_or(true, |existing| now > existing)
{
self.rate_limit_last_primary_reset_at = Some(now);
changed_primary = true;
}
}
if current.secondary_used_percent + 0.5 < prev.secondary_used_percent {
if self
.rate_limit_last_weekly_reset_at
.map_or(true, |existing| now > existing)
{
self.rate_limit_last_weekly_reset_at = Some(now);
changed_weekly = true;
}
}
} else {
if current.primary_used_percent <= 1.0
&& self.rate_limit_last_primary_reset_at.is_none()
{
self.rate_limit_last_primary_reset_at = Some(now);
changed_primary = true;
}
if current.secondary_used_percent <= 1.0
&& self.rate_limit_last_weekly_reset_at.is_none()
{
self.rate_limit_last_weekly_reset_at = Some(now);
changed_weekly = true;
}
}
if changed_primary || changed_weekly {
let candidate_info = self.rate_limit_reset_info();
let candidate_cache = RateLimitResetCache {
primary_last_reset: candidate_info.primary_last_reset,
weekly_last_reset: candidate_info.weekly_last_reset,
};
match persist_rate_limit_reset_cache(&self.config.codex_home, candidate_cache) {
Ok(merged) => {
self.rate_limit_last_primary_reset_at = merged.primary_last_reset;
self.rate_limit_last_weekly_reset_at = merged.weekly_last_reset;
}
Err(err) => {
tracing::warn!("failed to persist rate limit resets: {err:?}");
}
}
}
self.rate_limit_primary_next_reset_at = current
.primary_reset_after_seconds
.map(|secs| now + ChronoDuration::seconds(secs as i64));
self.rate_limit_secondary_next_reset_at = current
.secondary_reset_after_seconds
.map(|secs| now + ChronoDuration::seconds(secs as i64));
}
pub(crate) fn handle_update_command(&mut self) {
@@ -17199,94 +17145,6 @@ impl ChatWidget<'_> {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
struct RateLimitResetCache {
#[serde(default)]
primary_last_reset: Option<DateTime<Utc>>,
#[serde(default)]
weekly_last_reset: Option<DateTime<Utc>>,
}
fn load_rate_limit_reset_cache(codex_home: &Path) -> RateLimitResetCache {
let read_path = resolve_codex_path_for_read(codex_home, Path::new(RATE_LIMIT_RESET_FILE));
match std::fs::read_to_string(&read_path) {
Ok(contents) => match serde_json::from_str::<RateLimitResetCache>(&contents) {
Ok(cache) => cache,
Err(err) => {
tracing::debug!("failed to parse rate limit reset cache: {err:?}");
RateLimitResetCache::default()
}
},
Err(err) => {
if err.kind() != io::ErrorKind::NotFound {
tracing::debug!("failed to read rate limit reset cache: {err:?}");
}
RateLimitResetCache::default()
}
}
}
fn persist_rate_limit_reset_cache(
codex_home: &Path,
candidate: RateLimitResetCache,
) -> io::Result<RateLimitResetCache> {
if candidate.primary_last_reset.is_none() && candidate.weekly_last_reset.is_none() {
return Ok(candidate);
}
let path = rate_limit_reset_cache_path(codex_home);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.open(&path)?;
FileExt::lock_exclusive(&file)?;
let mut buf = String::new();
file.seek(SeekFrom::Start(0))?;
file.read_to_string(&mut buf)?;
let mut existing: RateLimitResetCache = if buf.trim().is_empty() {
RateLimitResetCache::default()
} else {
serde_json::from_str(&buf).unwrap_or_default()
};
let mut changed = false;
changed |= merge_timestamp(&mut existing.primary_last_reset, candidate.primary_last_reset);
changed |= merge_timestamp(&mut existing.weekly_last_reset, candidate.weekly_last_reset);
if changed {
let serialized = serde_json::to_string_pretty(&existing)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(serialized.as_bytes())?;
file.flush()?;
}
let _ = FileExt::unlock(&file);
Ok(existing)
}
fn rate_limit_reset_cache_path(codex_home: &Path) -> PathBuf {
codex_home.join(RATE_LIMIT_RESET_FILE)
}
fn merge_timestamp(dst: &mut Option<DateTime<Utc>>, candidate: Option<DateTime<Utc>>) -> bool {
match (dst.as_ref(), candidate) {
(_, None) => false,
(Some(existing), Some(candidate_dt)) if *existing >= candidate_dt => false,
_ => {
*dst = candidate;
true
}
}
}
impl WidgetRef for &ChatWidget<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Top-level widget render timing
@@ -5,7 +5,7 @@ use codex_core::{AuthManager, ModelClient, Prompt, ResponseEvent};
use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::debug_logger::DebugLogger;
use codex_core::protocol::{Event, EventMsg, TokenCountEvent, RateLimitSnapshotEvent as CoreRateLimitSnapshotEvent};
use codex_core::protocol::{Event, EventMsg, RateLimitSnapshotEvent, TokenCountEvent};
use codex_protocol::models::{ContentItem, ResponseItem};
use futures::StreamExt;
use tokio::runtime::Runtime;
@@ -80,13 +80,7 @@ fn run_refresh(
let proto_snapshot = snapshot.context("rate limit snapshot missing from response")?;
let snapshot = CoreRateLimitSnapshotEvent {
primary_used_percent: proto_snapshot.primary_used_percent,
secondary_used_percent: proto_snapshot.secondary_used_percent,
primary_to_secondary_ratio_percent: proto_snapshot.primary_to_secondary_ratio_percent,
primary_window_minutes: proto_snapshot.primary_window_minutes,
secondary_window_minutes: proto_snapshot.secondary_window_minutes,
};
let snapshot: RateLimitSnapshotEvent = proto_snapshot.clone();
let event = Event {
id: "rate-limit-refresh".to_string(),
+8
View File
@@ -196,7 +196,10 @@ fn limits_overlay_renders_snapshot() {
primary_to_secondary_ratio_percent: 0.0,
primary_window_minutes: 300,
secondary_window_minutes: 10_080,
primary_reset_after_seconds: Some(600),
secondary_reset_after_seconds: Some(3_600),
});
chat.update_rate_limit_resets(chat.rate_limit_snapshot.as_ref().unwrap());
chat.rate_limit_last_fetch_at = Some(Utc::now());
chat.add_limits_output();
@@ -218,6 +221,11 @@ fn limits_overlay_renders_snapshot() {
assert!(joined.contains("Weekly Limit"), "overlay text: {joined}");
assert!(joined.contains(" Type: "));
assert!(joined.contains(" Plan: "));
assert!(joined.contains("Resets"), "overlay text: {joined}");
assert!(
!joined.contains("awaiting reset timing"),
"expected reset timings to render: {joined}"
);
assert!(joined.contains(" Total: "));
assert!(joined.contains("Chart"));
assert!(joined.contains("7 Day History"));
+39 -38
View File
@@ -87,8 +87,8 @@ pub(crate) struct GridConfig {
#[derive(Clone, Debug, Default)]
pub(crate) struct RateLimitResetInfo {
pub(crate) primary_last_reset: Option<DateTime<Utc>>,
pub(crate) weekly_last_reset: Option<DateTime<Utc>>,
pub(crate) primary_next_reset: Option<DateTime<Utc>>,
pub(crate) secondary_next_reset: Option<DateTime<Utc>>,
pub(crate) session_tokens_used: Option<u64>,
pub(crate) auto_compact_limit: Option<u64>,
pub(crate) overflow_auto_compact: bool,
@@ -178,11 +178,11 @@ fn build_summary_lines(
));
lines.push(build_hourly_window_line(
metrics,
reset_info.primary_last_reset,
reset_info.primary_next_reset,
));
lines.push(build_hourly_reset_line(
snapshot.primary_window_minutes,
reset_info.primary_last_reset,
reset_info.primary_next_reset,
));
lines.push("".into());
@@ -196,11 +196,11 @@ fn build_summary_lines(
));
lines.push(build_weekly_window_line(
metrics.weekly_window_minutes,
reset_info.weekly_last_reset,
reset_info.secondary_next_reset,
));
lines.push(build_weekly_reset_line(
snapshot.secondary_window_minutes,
reset_info.weekly_last_reset,
reset_info.secondary_next_reset,
));
lines.push("".into());
@@ -240,7 +240,7 @@ fn build_bar_line(label: &str, percent: f64, suffix: &str, style: Style) -> Line
fn build_hourly_window_line(
metrics: &RateLimitMetrics,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
let prefix = field_prefix("Window");
if metrics.primary_window_minutes == 0 {
@@ -253,8 +253,8 @@ fn build_hourly_window_line(
]);
}
if let Some(last) = last_reset {
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, last) {
if let Some(next) = next_reset {
if let Some(timing) = compute_window_timing(metrics.primary_window_minutes, next) {
let window_secs = timing.window.as_secs_f64();
if window_secs > 0.0 {
let elapsed = timing.elapsed();
@@ -278,25 +278,25 @@ fn build_hourly_window_line(
}
}
Line::from(vec![
Span::raw(prefix),
Span::styled(
format!(
"(unknown / {})",
format_minutes_round_units(metrics.primary_window_minutes)
),
Style::default().fg(colors::dim()),
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix));
spans.push(Span::styled(
format!(
"(unknown / {})",
format_minutes_round_units(metrics.primary_window_minutes)
),
])
Style::default().fg(colors::dim()),
));
Line::from(spans)
}
fn build_hourly_reset_line(
window_minutes: u64,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
if let (Some(last), true) = (last_reset, window_minutes > 0) {
if let (Some(next), true) = (next_reset, window_minutes > 0) {
let prefix = field_prefix("Resets");
if let Some(timing) = compute_window_timing(window_minutes, last) {
if let Some(timing) = compute_window_timing(window_minutes, next) {
let remaining = format_duration(timing.remaining);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix.clone()));
@@ -320,7 +320,7 @@ fn build_hourly_reset_line(
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(field_prefix("Resets")));
spans.push(Span::styled(
"shown once next window detected".to_string(),
"awaiting reset timing…".to_string(),
Style::default().fg(colors::dim()),
));
Line::from(spans)
@@ -328,7 +328,7 @@ fn build_hourly_reset_line(
fn build_weekly_window_line(
weekly_minutes: u64,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
let prefix = field_prefix("Window");
if weekly_minutes == 0 {
@@ -341,8 +341,8 @@ fn build_weekly_window_line(
]);
}
if let Some(last) = last_reset {
if let Some(timing) = compute_window_timing(weekly_minutes, last) {
if let Some(next) = next_reset {
if let Some(timing) = compute_window_timing(weekly_minutes, next) {
let window_secs = timing.window.as_secs_f64();
if window_secs > 0.0 {
let elapsed = timing.elapsed();
@@ -379,11 +379,11 @@ fn build_weekly_window_line(
fn build_weekly_reset_line(
window_minutes: u64,
last_reset: Option<DateTime<Utc>>,
next_reset: Option<DateTime<Utc>>,
) -> Line<'static> {
if let (Some(last), true) = (last_reset, window_minutes > 0) {
if let (Some(next), true) = (next_reset, window_minutes > 0) {
let prefix = field_prefix("Resets");
if let Some(timing) = compute_window_timing(window_minutes, last) {
if let Some(timing) = compute_window_timing(window_minutes, next) {
let remaining = format_duration(timing.remaining);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(prefix.clone()));
@@ -406,7 +406,7 @@ fn build_weekly_reset_line(
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(field_prefix("Resets")));
spans.push(Span::styled(
"shown once next window detected".to_string(),
"awaiting reset timing…".to_string(),
Style::default().fg(colors::dim()),
));
Line::from(spans)
@@ -609,7 +609,7 @@ impl WindowTiming {
fn compute_window_timing(
window_minutes: u64,
last_reset: DateTime<Utc>,
next_reset: DateTime<Utc>,
) -> Option<WindowTiming> {
let window_seconds = (window_minutes as i64).checked_mul(60)?;
if window_seconds <= 0 {
@@ -617,17 +617,16 @@ fn compute_window_timing(
}
let now = Utc::now();
let elapsed_secs = now.signed_duration_since(last_reset).num_seconds();
let elapsed_within_window = if elapsed_secs <= 0 {
0
} else {
elapsed_secs.rem_euclid(window_seconds)
};
let remaining_secs = (window_seconds - elapsed_within_window).max(0);
let mut remaining_secs = next_reset.signed_duration_since(now).num_seconds();
if remaining_secs < 0 {
remaining_secs = 0;
}
if remaining_secs > window_seconds {
remaining_secs = window_seconds;
}
let window = Duration::from_secs(window_seconds as u64);
let remaining = Duration::from_secs(remaining_secs as u64);
let next_reset = now + chrono::Duration::seconds(remaining_secs);
Some(WindowTiming {
remaining,
window,
@@ -1020,6 +1019,8 @@ mod tests {
primary_to_secondary_ratio_percent: 40.0,
primary_window_minutes: 300,
secondary_window_minutes: 10_080,
primary_reset_after_seconds: None,
secondary_reset_after_seconds: None,
}
}