From 7215d069751ea1c6f72bab1ab31620d3535249c8 Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:51:56 +0300 Subject: [PATCH 1/2] Add guarded usage limit reset action --- src/app/mod.rs | 362 ++++++++++++++++++++++++++++++++++++++++++- src/codex_rpc/mod.rs | 59 +++++++ src/ui/mod.rs | 200 ++++++++++++++++++++++-- 3 files changed, 599 insertions(+), 22 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 762a9ce..07d01cf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,4 @@ -use crate::codex_rpc::{AccountRateLimits, AccountUsage, CodexRpc}; +use crate::codex_rpc::{AccountRateLimits, AccountUsage, CodexRpc, ResetCreditOutcome}; use crate::locale::{DisplayFormatter, DisplayStyle, SystemLocale}; use crate::read; use crate::usage::{ChartRange, LocalUsageSnapshot, UsageMetric, UsageZone}; @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::io::Stdout; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::sync::{mpsc, watch}; @@ -204,6 +205,7 @@ enum UsageCommand { enum AppEvent { UsageUpdated(Result), LimitsUpdated(Result), + LimitResetConsumed(Result), AccountUsageUpdated(Result), AccountApiUnavailable { message: String, @@ -257,6 +259,9 @@ pub(crate) enum UiClickAction { SetActivityScrollOffset(usize), DecreaseProjects, IncreaseProjects, + PromptLimitReset, + ConfirmLimitReset, + CancelLimitReset, PromptQuit, CancelQuit, ConfirmQuit, @@ -396,6 +401,12 @@ pub(crate) struct AppState { pub(crate) limits_error: Option, pub(crate) limits_notice: Option, pub(crate) limits_enabled: bool, + pub(crate) limit_reset_confirm_open: bool, + pub(crate) limit_reset_confirm_yes_selected: bool, + pub(crate) limit_reset_in_flight: bool, + pub(crate) limit_reset_cooldown_until: Option, + pub(crate) limit_reset_notice: Option, + pub(crate) limit_reset_error: Option, pub(crate) account_usage: Option, pub(crate) account_usage_updated_at: Option, @@ -405,20 +416,32 @@ pub(crate) struct AppState { pub(crate) read_browser: crate::read::tui::BrowserState, } -const STATE_STORE_SCHEMA_VERSION: u32 = 3; +const STATE_STORE_SCHEMA_VERSION: u32 = 4; const STATE_STORE_FILE_NAME: &str = "state.json"; const STATE_SAVE_DEBOUNCE: Duration = Duration::from_millis(400); const IDLE_UI_TICK: Duration = Duration::from_secs(1); +const LIMIT_RESET_COOLDOWN_SECS: i64 = 60 * 60; +const LIMIT_EXHAUSTED_EPSILON: f64 = 0.01; +static LIMIT_RESET_ATTEMPT_SEQUENCE: AtomicU64 = AtomicU64::new(1); pub(crate) const DEFAULT_ACTIVITY_PROJECT_LIMIT: usize = 5; pub(crate) const MIN_ACTIVITY_PROJECT_LIMIT: usize = 1; pub(crate) const MAX_ACTIVITY_PROJECT_LIMIT: usize = 50; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum LimitsWake { Poll, + ConsumeReset(String), Stop, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LimitResetButtonState { + Enabled, + Disabled, + Cooldown(u64), + InFlight, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PersistedUiState { metric: UsageMetric, @@ -435,6 +458,7 @@ struct PersistedUiState { accent_theme: AccentTheme, bar_fill_mode: BarFillMode, skip_quit_confirmation: bool, + limit_reset_cooldown_until: Option, history_project_view_mode: crate::read::catalog::ProjectViewMode, history_deep_depth: u8, history_selected_projects: BTreeSet, @@ -459,6 +483,7 @@ impl PersistedUiState { accent_theme: AccentTheme::default(), bar_fill_mode: BarFillMode::default(), skip_quit_confirmation: false, + limit_reset_cooldown_until: None, history_project_view_mode: crate::read::catalog::ProjectViewMode::Strict, history_deep_depth: crate::read::catalog::DEFAULT_DEEP_DEPTH, history_selected_projects: BTreeSet::new(), @@ -483,6 +508,9 @@ impl PersistedUiState { accent_theme: state.accent_theme, bar_fill_mode: state.bar_fill_mode, skip_quit_confirmation: state.skip_quit_confirmation, + limit_reset_cooldown_until: state + .limit_reset_cooldown_until + .filter(|until| *until > unix_time_seconds()), history_project_view_mode: state.read_browser.project_mode(), history_deep_depth: state.read_browser.deep_depth(), history_selected_projects: state.read_browser.selected_projects().clone(), @@ -529,6 +557,7 @@ struct StoredGlobalState { bar_fill_mode: Option, #[serde(default)] skip_quit_confirmation: bool, + limit_reset_cooldown_until: Option, last_workspace_path: Option, history_project_view_mode: Option, history_deep_depth: Option, @@ -562,6 +591,7 @@ async fn run_inner( let (evt_tx, mut evt_rx) = mpsc::channel::(64); let (usage_refresh_tx, usage_refresh_rx) = mpsc::channel::<()>(1); let (limits_refresh_tx, limits_refresh_rx) = mpsc::channel::<()>(1); + let (limit_reset_tx, limit_reset_rx) = mpsc::channel::(4); let (catalog_refresh_tx, catalog_refresh_rx) = mpsc::channel::<()>(1); let (shutdown_tx, shutdown_rx) = watch::channel(false); let restored_ui_state = load_persisted_ui_state_with_history_depth( @@ -808,6 +838,7 @@ async fn run_inner( let cwd = config.cwd.clone(); let refresh = Duration::from_secs(config.refresh_limits_secs); let mut limits_refresh_rx = limits_refresh_rx; + let mut limit_reset_rx = limit_reset_rx; let mut shutdown_rx = shutdown_rx.clone(); tokio::spawn(async move { if live_limits_mode == LiveLimitsMode::Off { @@ -879,6 +910,12 @@ async fn run_inner( LimitsWake::Poll } } + recv = limit_reset_rx.recv() => { + match recv { + Some(idempotency_key) => LimitsWake::ConsumeReset(idempotency_key), + None => LimitsWake::Stop, + } + } notification = rpc.recv_notification() => { match notification { Some(value) @@ -891,9 +928,25 @@ async fn run_inner( } } }; - if wake == LimitsWake::Stop { - rpc.kill().await; - break; + match wake { + LimitsWake::Stop => { + rpc.kill().await; + break; + } + LimitsWake::ConsumeReset(idempotency_key) => { + let result = rpc + .consume_account_rate_limit_reset_credit(&idempotency_key) + .await; + if evt_tx + .send(AppEvent::LimitResetConsumed(result)) + .await + .is_err() + { + rpc.kill().await; + break; + } + } + LimitsWake::Poll => {} } let res = rpc.read_account_rate_limits().await; if evt_tx.send(AppEvent::LimitsUpdated(res)).await.is_err() { @@ -998,6 +1051,14 @@ async fn run_inner( limits_error: None, limits_notice: None, limits_enabled: true, + limit_reset_confirm_open: false, + limit_reset_confirm_yes_selected: false, + limit_reset_in_flight: false, + limit_reset_cooldown_until: restored_ui_state + .limit_reset_cooldown_until + .filter(|until| *until > unix_time_seconds()), + limit_reset_notice: None, + limit_reset_error: None, account_usage: None, account_usage_updated_at: None, account_usage_error: None, @@ -1024,7 +1085,9 @@ async fn run_inner( input, &usage_refresh_tx, &limits_refresh_tx, + &limit_reset_tx, &catalog_refresh_tx, + &config.comon_home, )? { InputOutcome::Continue(should_redraw) => { dirty |= should_redraw; @@ -1117,12 +1180,109 @@ fn clear_scan_cache_files(path: &Path) -> Result<()> { Ok(()) } +fn new_limit_reset_idempotency_key() -> String { + let sequence = LIMIT_RESET_ATTEMPT_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("comon-{}-{nanos}-{sequence}", std::process::id()) +} + +fn begin_limit_reset( + state: &mut AppState, + limit_reset_tx: &mpsc::Sender, + comon_home: &Path, +) -> Result { + if state.limit_reset_button_state() != LimitResetButtonState::Enabled { + state.limit_reset_confirm_open = false; + state.limit_reset_confirm_yes_selected = false; + return Ok(true); + } + + state.limit_reset_confirm_open = false; + state.limit_reset_confirm_yes_selected = false; + state.limit_reset_in_flight = true; + state.limit_reset_cooldown_until = + Some(unix_time_seconds().saturating_add(LIMIT_RESET_COOLDOWN_SECS)); + state.limit_reset_notice = Some("Reset request queued; waiting for Codex.".to_string()); + state.limit_reset_error = None; + + // Persist the safety lock before the irreversible request. If persistence fails, do not + // consume a credit: an immediate restart could otherwise permit an accidental retry. + if let Err(error) = + save_persisted_ui_state(comon_home, &PersistedUiState::from_app_state(state)) + { + state.limit_reset_in_flight = false; + state.limit_reset_cooldown_until = None; + state.limit_reset_notice = None; + state.limit_reset_error = Some(format!("Unable to save reset cooldown: {error}")); + return Ok(true); + } + + let idempotency_key = new_limit_reset_idempotency_key(); + if let Err(error) = limit_reset_tx.try_send(idempotency_key) { + state.limit_reset_in_flight = false; + state.limit_reset_cooldown_until = None; + state.limit_reset_notice = None; + state.limit_reset_error = Some(format!("Unable to queue reset request: {error}")); + let _ = save_persisted_ui_state(comon_home, &PersistedUiState::from_app_state(state)); + } + Ok(true) +} + +fn handle_limit_reset_confirmation_input( + state: &mut AppState, + event: Event, + limit_reset_tx: &mpsc::Sender, + comon_home: &Path, +) -> Result { + let finish = |state: &mut AppState, confirmed: bool| -> Result { + if confirmed { + begin_limit_reset(state, limit_reset_tx, comon_home)?; + } else { + state.limit_reset_confirm_open = false; + state.limit_reset_confirm_yes_selected = false; + } + Ok(InputOutcome::Continue(true)) + }; + + match event { + Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { + match ui_click_action_at(&state.ui_hit_targets, mouse.column, mouse.row) { + Some(UiClickAction::ConfirmLimitReset) => finish(state, true), + Some(UiClickAction::CancelLimitReset) => finish(state, false), + _ => Ok(InputOutcome::Continue(false)), + } + } + Event::Key(key) if key.kind == KeyEventKind::Press => match (key.code, key.modifiers) { + (KeyCode::Char('y'), _) | (KeyCode::Char('Y'), _) => finish(state, true), + (KeyCode::Enter, _) => finish(state, state.limit_reset_confirm_yes_selected), + (KeyCode::Left, _) | (KeyCode::Right, _) | (KeyCode::Tab, _) => { + state.limit_reset_confirm_yes_selected = !state.limit_reset_confirm_yes_selected; + Ok(InputOutcome::Continue(true)) + } + (KeyCode::Char('c'), KeyModifiers::CONTROL) => Ok(InputOutcome::Quit), + (KeyCode::Esc, _) + | (KeyCode::Char('n'), _) + | (KeyCode::Char('N'), _) + | (KeyCode::Char('q'), _) + | (KeyCode::Char('Q'), _) => finish(state, false), + _ => Ok(InputOutcome::Continue(false)), + }, + Event::Resize(_, _) => Ok(InputOutcome::Continue(true)), + _ => Ok(InputOutcome::Continue(false)), + } +} + fn handle_input_event( state: &mut AppState, event: Event, usage_refresh_tx: &mpsc::Sender<()>, limits_refresh_tx: &mpsc::Sender<()>, + limit_reset_tx: &mpsc::Sender, catalog_refresh_tx: &mpsc::Sender<()>, + comon_home: &Path, ) -> Result { if state.history_catalog_scan_prompt { return match event { @@ -1172,6 +1332,10 @@ fn handle_input_event( }; } + if state.limit_reset_confirm_open { + return handle_limit_reset_confirmation_input(state, event, limit_reset_tx, comon_home); + } + if let Some(desired_skip_confirmation) = state.quit_preference_prompt { return match event { Event::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => { @@ -1618,6 +1782,15 @@ fn apply_ui_click_action(state: &mut AppState, action: UiClickAction) -> bool { state.activity_project_limit = next; changed } + UiClickAction::PromptLimitReset => { + if state.limit_reset_button_state() != LimitResetButtonState::Enabled { + return false; + } + state.limit_reset_confirm_open = true; + state.limit_reset_confirm_yes_selected = false; + true + } + UiClickAction::ConfirmLimitReset | UiClickAction::CancelLimitReset => false, UiClickAction::PromptQuit => { state.quit_confirm_open = true; state.quit_confirm_yes_selected = false; @@ -2096,6 +2269,42 @@ fn handle_app_event(state: &mut AppState, evt: AppEvent) -> bool { } true } + AppEvent::LimitResetConsumed(res) => { + state.limit_reset_in_flight = false; + match res { + Ok(ResetCreditOutcome::Reset) => { + state.limit_reset_notice = + Some("Limit reset accepted; refreshing server statistics.".to_string()); + state.limit_reset_error = None; + } + Ok(ResetCreditOutcome::AlreadyRedeemed) => { + state.limit_reset_notice = Some( + "This reset attempt was already redeemed; refreshing server statistics." + .to_string(), + ); + state.limit_reset_error = None; + } + Ok(ResetCreditOutcome::NothingToReset) => { + state.limit_reset_cooldown_until = None; + state.limit_reset_notice = + Some("Codex reports that no limit is eligible for reset.".to_string()); + state.limit_reset_error = None; + } + Ok(ResetCreditOutcome::NoCredit) => { + state.limit_reset_cooldown_until = None; + state.limit_reset_notice = + Some("No reset credit is currently available.".to_string()); + state.limit_reset_error = None; + } + Err(error) => { + // Keep the cooldown after an ambiguous transport/server failure: the backend + // may have consumed the credit even when the response did not arrive. + state.limit_reset_notice = None; + state.limit_reset_error = Some(format!("Limit reset failed: {error}")); + } + } + true + } AppEvent::AccountUsageUpdated(res) => { match res { Ok(usage) => { @@ -2217,6 +2426,10 @@ fn load_persisted_ui_state_with_history_depth( } } state.skip_quit_confirmation = store.global.skip_quit_confirmation; + state.limit_reset_cooldown_until = store + .global + .limit_reset_cooldown_until + .filter(|until| *until > unix_time_seconds()); if let Some(mode_text) = store.global.history_project_view_mode.as_deref() { if let Some(mode) = crate::read::catalog::ProjectViewMode::from_store(mode_text) { state.history_project_view_mode = mode; @@ -2280,6 +2493,9 @@ fn save_persisted_ui_state(comon_home: &Path, state: &PersistedUiState) -> Resul store.global.accent_theme = Some(state.accent_theme.store_value().to_string()); store.global.bar_fill_mode = Some(state.bar_fill_mode.store_value().to_string()); store.global.skip_quit_confirmation = state.skip_quit_confirmation; + store.global.limit_reset_cooldown_until = state + .limit_reset_cooldown_until + .filter(|until| *until > now); store.global.history_project_view_mode = Some(state.history_project_view_mode.store_value().to_string()); store.global.history_deep_depth = Some( @@ -2475,12 +2691,80 @@ impl AppState { let updated_at = self.account_usage_updated_at?; Some(crate::ui::format_updated_label(updated_at)) } + + pub(crate) fn limit_reset_button_state(&self) -> LimitResetButtonState { + if self.limit_reset_in_flight { + return LimitResetButtonState::InFlight; + } + let now = unix_time_seconds(); + if let Some(remaining) = self + .limit_reset_cooldown_until + .and_then(|until| until.checked_sub(now)) + .filter(|remaining| *remaining > 0) + { + return LimitResetButtonState::Cooldown(remaining as u64); + } + if self.limits_enabled && self.limits.as_ref().is_some_and(limit_reset_is_available) { + LimitResetButtonState::Enabled + } else { + LimitResetButtonState::Disabled + } + } + + pub(crate) fn limit_reset_disabled_reason(&self) -> &'static str { + if !self.limits_enabled || self.limits.is_none() { + "Limits are unavailable." + } else if self + .limits + .as_ref() + .and_then(|limits| limits.reset_credits_available) + .unwrap_or(0) + <= 0 + { + "No reset credits are available." + } else { + "Reset becomes available when a limit reaches 0% remaining." + } + } +} + +fn limit_reset_is_available(limits: &AccountRateLimits) -> bool { + limits.reset_credits_available.unwrap_or(0) > 0 && account_has_exhausted_limit(limits) +} + +fn account_has_exhausted_limit(limits: &AccountRateLimits) -> bool { + snapshot_has_exhausted_limit( + limits.primary.as_ref(), + limits.secondary.as_ref(), + limits.individual_limit.as_ref(), + ) || limits.buckets.iter().any(|bucket| { + snapshot_has_exhausted_limit( + bucket.primary.as_ref(), + bucket.secondary.as_ref(), + bucket.individual_limit.as_ref(), + ) + }) +} + +fn snapshot_has_exhausted_limit( + primary: Option<&crate::codex_rpc::RateLimitWindow>, + secondary: Option<&crate::codex_rpc::RateLimitWindow>, + individual: Option<&crate::codex_rpc::SpendControlLimitSnapshot>, +) -> bool { + [primary, secondary].into_iter().flatten().any(|window| { + window + .used_percent + .is_some_and(|used| used.is_finite() && used >= 100.0 - LIMIT_EXHAUSTED_EPSILON) + }) || individual.is_some_and(|limit| { + limit + .remaining_percent + .is_some_and(|remaining| remaining.is_finite() && remaining <= LIMIT_EXHAUSTED_EPSILON) + }) } #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicU64, Ordering}; static TEMP_ID_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -2607,6 +2891,56 @@ mod tests { assert!(!should_continue_scan_catch_up(0, 10, 1_000, Some((9, 900)))); } + fn reset_test_limits(used_percent: Option, available: i64) -> AccountRateLimits { + AccountRateLimits { + limit_id: Some("codex".to_string()), + limit_name: None, + individual_limit: None, + primary: Some(crate::codex_rpc::RateLimitWindow { + used_percent, + window_duration_mins: Some(300.0), + resets_at: None, + }), + secondary: None, + credits: None, + buckets: Vec::new(), + reset_credits_available: Some(available), + reset_credits: None, + } + } + + #[test] + fn reset_requires_both_a_credit_and_an_exhausted_limit() { + assert!(limit_reset_is_available(&reset_test_limits( + Some(99.999), + 1 + ))); + assert!(!limit_reset_is_available(&reset_test_limits(Some(99.0), 1))); + assert!(!limit_reset_is_available(&reset_test_limits( + Some(100.0), + 0 + ))); + } + + #[test] + fn reset_detects_an_exhausted_individual_limit_in_a_bucket() { + let mut limits = reset_test_limits(Some(20.0), 1); + limits.buckets.push(crate::codex_rpc::RateLimitSnapshot { + limit_id: Some("monthly".to_string()), + limit_name: None, + individual_limit: Some(crate::codex_rpc::SpendControlLimitSnapshot { + limit: None, + remaining_percent: Some(0.0), + resets_at: None, + used: None, + }), + primary: None, + secondary: None, + credits: None, + }); + assert!(limit_reset_is_available(&limits)); + } + fn make_temp_dir(prefix: &str) -> PathBuf { let unique = format!( "{}-{}-{}", @@ -2720,6 +3054,20 @@ mod tests { let _ = std::fs::remove_dir_all(comon_home); } + #[test] + fn active_reset_cooldown_round_trips_through_state_store() { + let comon_home = make_temp_dir("limit-reset-cooldown"); + let mut state = PersistedUiState::default_for_workspace(None); + let cooldown_until = unix_time_seconds().saturating_add(3_600); + state.limit_reset_cooldown_until = Some(cooldown_until); + + save_persisted_ui_state(&comon_home, &state).expect("save persisted ui state"); + let loaded = load_persisted_ui_state(&comon_home, None).expect("load persisted ui state"); + assert_eq!(loaded.limit_reset_cooldown_until, Some(cooldown_until)); + + let _ = std::fs::remove_dir_all(comon_home); + } + #[test] fn legacy_state_without_theme_settings_uses_defaults() { let comon_home = make_temp_dir("legacy-theme-settings"); diff --git a/src/codex_rpc/mod.rs b/src/codex_rpc/mod.rs index 5273442..f586b27 100644 --- a/src/codex_rpc/mod.rs +++ b/src/codex_rpc/mod.rs @@ -78,6 +78,14 @@ pub struct AccountRateLimits { pub reset_credits: Option>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResetCreditOutcome { + Reset, + NothingToReset, + NoCredit, + AlreadyRedeemed, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AccountUsageSummary { pub lifetime_tokens: Option, @@ -391,6 +399,25 @@ impl CodexRpc { parse_account_usage_result(&result) } + pub async fn consume_account_rate_limit_reset_credit( + &self, + idempotency_key: &str, + ) -> Result { + let value = self + .send_request( + "account/rateLimitResetCredit/consume", + json!({ "idempotencyKey": idempotency_key }), + ) + .await?; + + if let Some(err) = value.get("error") { + return Err(anyhow!("account/rateLimitResetCredit/consume error: {err}")); + } + + let result = value.get("result").cloned().unwrap_or(Value::Null); + parse_reset_credit_outcome(&result) + } + pub async fn recv_notification(&self) -> Option { self.notifications.lock().await.recv().await } @@ -405,6 +432,17 @@ pub fn is_account_rate_limits_updated_notification(value: &Value) -> bool { value.get("method").and_then(|v| v.as_str()) == Some("account/rateLimits/updated") } +fn parse_reset_credit_outcome(result: &Value) -> Result { + match result.get("outcome").and_then(Value::as_str) { + Some("reset") => Ok(ResetCreditOutcome::Reset), + Some("nothingToReset") => Ok(ResetCreditOutcome::NothingToReset), + Some("noCredit") => Ok(ResetCreditOutcome::NoCredit), + Some("alreadyRedeemed") => Ok(ResetCreditOutcome::AlreadyRedeemed), + Some(other) => Err(anyhow!("unknown reset-credit outcome: {other}")), + None => Err(anyhow!("reset-credit outcome missing")), + } +} + fn non_empty_string(value: String) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { @@ -984,6 +1022,27 @@ mod tests { ); } + #[test] + fn parses_all_reset_credit_outcomes() { + for (raw, expected) in [ + ("reset", ResetCreditOutcome::Reset), + ("nothingToReset", ResetCreditOutcome::NothingToReset), + ("noCredit", ResetCreditOutcome::NoCredit), + ("alreadyRedeemed", ResetCreditOutcome::AlreadyRedeemed), + ] { + assert_eq!( + parse_reset_credit_outcome(&json!({ "outcome": raw })).expect("outcome"), + expected + ); + } + } + + #[test] + fn rejects_unknown_or_missing_reset_credit_outcome() { + assert!(parse_reset_credit_outcome(&json!({ "outcome": "futureValue" })).is_err()); + assert!(parse_reset_credit_outcome(&json!({})).is_err()); + } + #[test] fn parse_account_usage_result_accepts_summary_and_sorts_daily_buckets() { let value = json!({ diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3d57018..a88f137 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,6 +1,6 @@ use crate::app::{ AccentTheme, ActiveScreen, ApiStatGraph, ApiStatGrouping, AppState, BarFillMode, - ChartOrientation, UiClickAction, UiHitTarget, + ChartOrientation, LimitResetButtonState, UiClickAction, UiHitTarget, }; use crate::locale::{DisplayFormatter, DisplayStyle}; use crate::usage::{ @@ -246,6 +246,8 @@ pub fn render(frame: &mut Frame<'_>, state: &mut AppState) { if state.history_catalog_scan_prompt { render_history_catalog_scan_confirmation(frame, area, state); + } else if state.limit_reset_confirm_open { + render_limit_reset_confirmation(frame, area, state); } else if state.quit_confirm_open { render_quit_confirmation(frame, area, state); } else if state.quit_preference_prompt.is_some() { @@ -1975,7 +1977,8 @@ fn footer_error(state: &AppState) -> String { ActiveScreen::Usage => state .usage_error .as_deref() - .or(state.limits_error.as_deref()), + .or(state.limits_error.as_deref()) + .or(state.limit_reset_error.as_deref()), ActiveScreen::Activity => state.usage_error.as_deref(), ActiveScreen::ApiStat => state.account_usage_error.as_deref(), ActiveScreen::LimitResets => state.limits_error.as_deref(), @@ -2028,7 +2031,10 @@ fn render_usage(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { let chunks = usage_layout(area, controls_height, cards_height); render_usage_controls(frame, chunks[0], state, reset_summary.as_deref()); - let weekly_hover = render_usage_cards(frame, chunks[1], state); + let (weekly_hover, limit_reset_target) = render_usage_cards(frame, chunks[1], state); + if let Some(target) = limit_reset_target { + state.ui_hit_targets.push(target); + } render_usage_chart(frame, chunks[2], state); render_top_models(frame, chunks[3], state); if let Some(hover) = weekly_hover { @@ -2067,7 +2073,7 @@ fn render_activity(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { .split(area); render_activity_controls(frame, chunks[0], state, reset_summary.as_deref()); - let weekly_hover = render_usage_cards(frame, chunks[1], state); + let (weekly_hover, _) = render_usage_cards(frame, chunks[1], state); render_activity_heatmaps(frame, chunks[2], state); if let Some(hover) = weekly_hover { render_weekly_pace_tooltip(frame, area, hover.mouse, &hover.text); @@ -3279,8 +3285,9 @@ fn render_usage_cards( frame: &mut Frame<'_>, area: Rect, state: &AppState, -) -> Option { +) -> (Option, Option) { let mut weekly_hover: Option = None; + let mut limit_reset_target: Option = None; let formatter = state.formatter(); let today_now = match state.usage_zone { UsageZone::Local => Local::now().naive_local(), @@ -3342,11 +3349,12 @@ fn render_usage_cards( Constraint::Percentage(16), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, cards[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3376,11 +3384,12 @@ fn render_usage_cards( Constraint::Percentage(33), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, top[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3409,7 +3418,7 @@ fn render_usage_cards( render_pending(frame, aux_title, bottom[1]); render_pending(frame, "PEAK_DAY", bottom[2]); } - return weekly_hover; + return (weekly_hover, limit_reset_target); } // TODAY card always shows Tokens / Runs / Time. @@ -3499,11 +3508,12 @@ fn render_usage_cards( Constraint::Percentage(16), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, cards[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3550,11 +3560,12 @@ fn render_usage_cards( Constraint::Percentage(33), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, top[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3649,11 +3660,12 @@ fn render_usage_cards( Constraint::Percentage(16), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, cards[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3697,11 +3709,12 @@ fn render_usage_cards( Constraint::Percentage(33), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, top[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3834,11 +3847,12 @@ fn render_usage_cards( Constraint::Percentage(16), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, cards[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3880,11 +3894,12 @@ fn render_usage_cards( Constraint::Percentage(33), ]) .split(row1); - if let Some(hover) = render_limits_card( + if let Some(hover) = render_usage_limits_card( frame, top[0], state, uses_compact_limit_lines(card_layout.min_card_width), + &mut limit_reset_target, ) { weekly_hover = Some(hover); }; @@ -3931,7 +3946,7 @@ fn render_usage_cards( } } } - weekly_hover + (weekly_hover, limit_reset_target) } fn aggregate_usage_days(days: &[UsageDay], grouping: ChartRange) -> Vec { @@ -5265,6 +5280,73 @@ fn render_history_catalog_scan_confirmation( ); } +fn render_limit_reset_confirmation(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { + state.ui_hit_targets.clear(); + + let popup = centered_rect(area.width.min(58), area.height.min(11), area); + frame.render_widget(Clear, popup); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title(Span::styled( + " Use reset credit ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + popup, + ); + + let message_area = Rect::new( + popup.x.saturating_add(2), + popup.y.saturating_add(2), + popup.width.saturating_sub(4), + popup.height.saturating_sub(5), + ); + frame.render_widget( + Paragraph::new(Text::from(vec![ + Line::from("Consume one reset credit and reset exhausted limits?"), + Line::from(""), + Line::from(Span::styled( + "The RESET button will be locked for one hour.", + Style::default().fg(Color::Gray), + )), + Line::from(Span::styled( + "Y confirms; N/Esc cancels.", + Style::default().fg(Color::Gray), + )), + ])) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + message_area, + ); + + let buttons_area = Rect::new( + popup.x.saturating_add(1), + popup.y.saturating_add(popup.height.saturating_sub(2)), + popup.width.saturating_sub(2), + 1, + ); + let segments = [ + (" YES ", Some(UiClickAction::ConfirmLimitReset)), + (" ", None), + (" NO ", Some(UiClickAction::CancelLimitReset)), + ]; + state + .ui_hit_targets + .extend(centered_targets(buttons_area, &segments)); + frame.render_widget( + Paragraph::new(Line::from(vec![ + pill("YES", state.limit_reset_confirm_yes_selected), + Span::raw(" "), + pill("NO", !state.limit_reset_confirm_yes_selected), + ])) + .alignment(Alignment::Center), + buttons_area, + ); +} + fn render_quit_confirmation(frame: &mut Frame<'_>, area: Rect, state: &mut AppState) { state.ui_hit_targets.clear(); @@ -6611,6 +6693,94 @@ fn render_limits_card( Some(WeeklyPaceHover { mouse, text }) } +fn render_usage_limits_card( + frame: &mut Frame<'_>, + area: Rect, + state: &AppState, + compact: bool, + reset_target: &mut Option, +) -> Option { + let weekly_hover = render_limits_card(frame, area, state, compact); + if state.active_screen != ActiveScreen::Usage { + return weekly_hover; + } + + let button_state = state.limit_reset_button_state(); + let (label, style, tooltip, clickable) = match button_state { + LimitResetButtonState::Enabled => ( + " RESET ".to_string(), + Style::default() + .fg(Color::Black) + .bg(state.accent_text_color()) + .add_modifier(Modifier::BOLD), + "Use one reset credit for exhausted limit windows.".to_string(), + true, + ), + LimitResetButtonState::Disabled => ( + " RESET ".to_string(), + Style::default().fg(Color::DarkGray), + state.limit_reset_disabled_reason().to_string(), + false, + ), + LimitResetButtonState::Cooldown(remaining_secs) => { + let minutes = remaining_secs.div_ceil(60); + let time = if minutes >= 60 { + "1h".to_string() + } else { + format!("{minutes}m") + }; + ( + format!(" RESET {time} "), + Style::default().fg(Color::Gray), + format!("Reset cooldown active for about {time}."), + false, + ) + } + LimitResetButtonState::InFlight => ( + " RESET... ".to_string(), + Style::default() + .fg(state.accent_text_color()) + .add_modifier(Modifier::BOLD), + "Reset request is in progress.".to_string(), + false, + ), + }; + let width = u16::try_from(UnicodeWidthStr::width(label.as_str())).unwrap_or(u16::MAX); + if width == 0 || area.width < width.saturating_add(12) || area.height == 0 { + return weekly_hover; + } + let button_area = Rect::new( + area.x + .saturating_add(area.width.saturating_sub(width).saturating_sub(1)), + area.y, + width, + 1, + ); + frame.render_widget(Paragraph::new(Span::styled(label, style)), button_area); + if clickable { + *reset_target = Some(UiHitTarget { + area: button_area, + action: UiClickAction::PromptLimitReset, + }); + } + + if state + .mouse_position + .is_some_and(|mouse| rect_contains(button_area, mouse)) + { + return state.mouse_position.map(|mouse| WeeklyPaceHover { + mouse, + text: state + .limit_reset_error + .as_deref() + .or(state.limit_reset_notice.as_deref()) + .unwrap_or(&tooltip) + .to_string(), + }); + } + weekly_hover +} + fn individual_limit_for_limits( limits: &crate::codex_rpc::AccountRateLimits, ) -> Option<&crate::codex_rpc::SpendControlLimitSnapshot> { From 2bf170049d236bbff51ac81d7aac4709c8fd9f2c Mon Sep 17 00:00:00 2001 From: woffko <2505149+woffko@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:58:37 +0300 Subject: [PATCH 2/2] Refine reset button layout and bump 0.5.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/ui/mod.rs | 22 +++++++++++++--------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2536c45..6f671e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "comon" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index fb6cd43..2ab4329 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "comon" -version = "0.5.0" +version = "0.5.1" edition = "2021" rust-version = "1.88" license = "Apache-2.0" diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a88f137..9a02607 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6711,7 +6711,7 @@ fn render_usage_limits_card( " RESET ".to_string(), Style::default() .fg(Color::Black) - .bg(state.accent_text_color()) + .bg(Color::White) .add_modifier(Modifier::BOLD), "Use one reset credit for exhausted limit windows.".to_string(), true, @@ -6751,11 +6751,12 @@ fn render_usage_limits_card( } let button_area = Rect::new( area.x - .saturating_add(area.width.saturating_sub(width).saturating_sub(1)), - area.y, + .saturating_add(area.width.saturating_sub(width).saturating_sub(2)), + area.y.saturating_add(area.height / 2), width, 1, ); + frame.render_widget(Clear, button_area); frame.render_widget(Paragraph::new(Span::styled(label, style)), button_area); if clickable { *reset_target = Some(UiHitTarget { @@ -6770,12 +6771,15 @@ fn render_usage_limits_card( { return state.mouse_position.map(|mouse| WeeklyPaceHover { mouse, - text: state - .limit_reset_error - .as_deref() - .or(state.limit_reset_notice.as_deref()) - .unwrap_or(&tooltip) - .to_string(), + text: match button_state { + LimitResetButtonState::Cooldown(_) | LimitResetButtonState::InFlight => state + .limit_reset_error + .as_deref() + .or(state.limit_reset_notice.as_deref()) + .unwrap_or(&tooltip) + .to_string(), + LimitResetButtonState::Enabled | LimitResetButtonState::Disabled => tooltip, + }, }); } weekly_hover