From 018f8a3895d8b832cb0ad5cd6d326a7f790602f8 Mon Sep 17 00:00:00 2001 From: ognjeeen Date: Mon, 31 Aug 2026 11:26:22 +0200 Subject: [PATCH 1/5] Add rate-limit reset redemption --- README.md | 8 +- docs/ARCHITECTURE.md | 24 +- src/CodexUsageWidget/App.xaml.cs | 7 +- .../Application/IRateLimitResetConsumer.cs | 8 + .../Application/RateLimitResetOutcome.cs | 9 + .../Application/WidgetVisibilityController.cs | 6 +- .../Codex/CodexRateLimitResetConsumer.cs | 53 +++ .../Preview/PreviewUsageProvider.cs | 59 ++- src/CodexUsageWidget/Resources/Strings.resx | 14 + .../Resources/Strings.zh-CN.resx | 14 + .../Views/Controls/DetailedUsageView.xaml | 225 +++++++++++ .../Views/Controls/DetailedUsageView.xaml.cs | 55 +++ .../RateLimitResetRequestedEventArgs.cs | 9 + src/CodexUsageWidget/Views/MainWindow.xaml.cs | 106 +++++- .../RateLimitResetConfirmationWindow.xaml | 70 ++++ .../RateLimitResetConfirmationWindow.xaml.cs | 31 ++ .../Views/Resources/WidgetTheme.xaml | 356 +++++++++++++++--- .../RateLimitResetSummaryViewModel.cs | 15 + .../Views/ViewModels/UsageWidgetViewModel.cs | 62 ++- .../CodexRateLimitResetConsumerTests.cs | 119 ++++++ .../PreviewUsageProviderTests.cs | 22 ++ .../RateLimitResetViewModelTests.cs | 76 ++++ .../WidgetVisibilityControllerTests.cs | 22 ++ 23 files changed, 1289 insertions(+), 81 deletions(-) create mode 100644 src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs create mode 100644 src/CodexUsageWidget/Application/RateLimitResetOutcome.cs create mode 100644 src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs create mode 100644 src/CodexUsageWidget/Views/Controls/RateLimitResetRequestedEventArgs.cs create mode 100644 src/CodexUsageWidget/Views/RateLimitResetConfirmationWindow.xaml create mode 100644 src/CodexUsageWidget/Views/RateLimitResetConfirmationWindow.xaml.cs create mode 100644 src/CodexUsageWidget/Views/ViewModels/RateLimitResetSummaryViewModel.cs create mode 100644 tests/CodexUsageWidget.Tests/CodexRateLimitResetConsumerTests.cs create mode 100644 tests/CodexUsageWidget.Tests/RateLimitResetViewModelTests.cs diff --git a/README.md b/README.md index 46b5ed7..4c8e975 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ of `codex.cmd` or `codex.exe`. - Remaining percentage and reset time for every general Codex usage window - A selectable displayed limit shared by the widget headline, taskbar label, and tray icon - Compact and detailed desktop-widget layouts selected from Settings or the quick chevron, - including credits, spend controls, earned resets, token activity, and model-specific limits - when Codex returns them + including credits, spend controls, earned-reset expiration and redemption, token activity, + and model-specific limits when Codex returns them - System, light, and dark themes with five preset accent colors selected from Settings - Automatic Windows-language selection with English fallback, plus English and Simplified Chinese overrides in Settings @@ -64,6 +64,10 @@ widget falls back to an available window when Codex does not return the selected Token activity is informational. Token counts do not map directly to the remaining subscription percentage. +When earned rate-limit resets are available, expand **Rate-limit resets** in the detailed +widget to see each expiration time. **Use reset** always requires confirmation, consumes the +selected earned reset, and lets Codex apply it to an eligible rate-limit window. + ![Codex Usage Widget taskbar label preview](docs/images/taskbar-label.png) Select the `−` button to move the widget to the taskbar. Right-click the taskbar label or diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4d01bb..49ebe5f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,24 +27,26 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence the widget object graph. 2. `UsageMonitor` owns refresh scheduling, timeout handling and refresh coalescing. 3. `CodexUsageProvider` coordinates required rate-limit reads and optional token-activity reads. -4. `CodexAppServerSession` owns initialized app-server connection lifetime. -5. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime. -6. Endpoint-specific parsers convert Codex payloads into domain records. -7. A path-independent PowerShell hook bridge forwards minimal lifecycle signals to +4. `CodexRateLimitResetConsumer` owns explicit earned-reset redemption, including one stable + idempotency key for retries until the server returns a definitive outcome. +5. `CodexAppServerSession` owns initialized app-server connection lifetime. +6. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime. +7. Endpoint-specific parsers convert Codex payloads into domain records. +8. A path-independent PowerShell hook bridge forwards minimal lifecycle signals to `CodexActivityPipeSignalSource` over a current-user-only named pipe; `CodexActivityMonitor` owns one active turn per session and emits only final boolean transitions. -8. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads +9. `CodexActivityHookSetupService` coordinates reviewable hook-file changes and reads trust state through `hooks/list`; `CodexHookTrustStatusParser` owns the protocol shape. -9. `ActivityHookSetupControl` presents setup status inside Settings while a separate review +10. `ActivityHookSetupControl` presents setup status inside Settings while a separate review dialog shows the exact proposed file content before installation or removal. -10. `UsageWidgetViewModel` maps snapshots to immutable presentation state. -11. `AppThemeController` applies the saved system, light, or dark theme plus the selected +11. `UsageWidgetViewModel` maps snapshots to immutable presentation state. +12. `AppThemeController` applies the saved system, light, or dark theme plus the selected accent palette, and observes Windows theme changes without leaking registry access into view code. -12. `AppLanguageController` resolves the saved system, English, or Simplified Chinese +13. `AppLanguageController` resolves the saved system, English, or Simplified Chinese preference, while standard .NET resources and a notifying WPF binding refresh existing UI. -13. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates +14. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates activity-hook setup plus immediate theme, accent, widget-layout, displayed-limit, and Windows startup preferences. Focused user controls render compact, detailed, and repeated limit-row content. @@ -63,6 +65,8 @@ tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence - Failed app-server startup is disposed before a later refresh reconnects. - Optional token-activity failures degrade only the detailed activity section; core rate-limit monitoring remains available. +- Usage preview mode owns synthetic reset credits and redemption outcomes, so UI tests and + manual preview checks never consume a real account reset. - A semaphore prevents concurrent refreshes and a mutex prevents duplicate apps. - Activity hook IPC is bounded and local to the current Windows user. The hook path does not depend on the release extraction directory and does not start WPF. Accepted clients are diff --git a/src/CodexUsageWidget/App.xaml.cs b/src/CodexUsageWidget/App.xaml.cs index 81678cd..dca2e22 100644 --- a/src/CodexUsageWidget/App.xaml.cs +++ b/src/CodexUsageWidget/App.xaml.cs @@ -53,12 +53,16 @@ protected override void OnStartup(StartupEventArgs e) var appServerSession = new CodexAppServerSession(); var codexUsageProvider = new CodexUsageProvider(appServerSession); IUsageProvider usageProvider = codexUsageProvider; + IRateLimitResetConsumer resetConsumer = + new CodexRateLimitResetConsumer(appServerSession); var usagePreviewEnabled = false; #if DEBUG || USAGE_PREVIEW if (e.Args.Contains("--preview-usage", StringComparer.OrdinalIgnoreCase)) { usagePreviewEnabled = true; - usageProvider = new PreviewUsageProvider(codexUsageProvider); + var previewProvider = new PreviewUsageProvider(codexUsageProvider); + usageProvider = previewProvider; + resetConsumer = previewProvider; _logger.Info("Usage preview mode is active."); } #endif @@ -89,6 +93,7 @@ protected override void OnStartup(StartupEventArgs e) var window = new MainWindow( usageMonitor, + resetConsumer, activityMonitor, activityHookSetupService, new CodexCliLauncher(), diff --git a/src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs b/src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs new file mode 100644 index 0000000..5d61f54 --- /dev/null +++ b/src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs @@ -0,0 +1,8 @@ +namespace CodexUsageWidget.Application; + +public interface IRateLimitResetConsumer +{ + Task ConsumeAsync( + string? creditId, + CancellationToken cancellationToken = default); +} diff --git a/src/CodexUsageWidget/Application/RateLimitResetOutcome.cs b/src/CodexUsageWidget/Application/RateLimitResetOutcome.cs new file mode 100644 index 0000000..09ca5c5 --- /dev/null +++ b/src/CodexUsageWidget/Application/RateLimitResetOutcome.cs @@ -0,0 +1,9 @@ +namespace CodexUsageWidget.Application; + +public enum RateLimitResetOutcome +{ + Reset, + AlreadyRedeemed, + NothingToReset, + NoCredit +} diff --git a/src/CodexUsageWidget/Application/WidgetVisibilityController.cs b/src/CodexUsageWidget/Application/WidgetVisibilityController.cs index ec89e25..6ee9aa2 100644 --- a/src/CodexUsageWidget/Application/WidgetVisibilityController.cs +++ b/src/CodexUsageWidget/Application/WidgetVisibilityController.cs @@ -15,9 +15,11 @@ public WidgetVisibilityController(Func isVisible, Action show, Action hide public void Show() => _show(); - public void HideOnDeactivated(bool taskbarInteractionInProgress) + public void HideOnDeactivated( + bool taskbarInteractionInProgress, + bool ownedDialogOpen = false) { - if (!taskbarInteractionInProgress) + if (!taskbarInteractionInProgress && !ownedDialogOpen) { _hide(); } diff --git a/src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs b/src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs new file mode 100644 index 0000000..064a5ed --- /dev/null +++ b/src/CodexUsageWidget/Infrastructure/Codex/CodexRateLimitResetConsumer.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using CodexUsageWidget.Application; + +namespace CodexUsageWidget.Infrastructure.Codex; + +public sealed class CodexRateLimitResetConsumer( + ICodexAppServerSession session) : IRateLimitResetConsumer +{ + private readonly ConcurrentDictionary _pendingAttempts = new(); + + public async Task ConsumeAsync( + string? creditId, + CancellationToken cancellationToken = default) + { + if (creditId is not null && string.IsNullOrWhiteSpace(creditId)) + { + throw new ArgumentException("Credit ID cannot be empty.", nameof(creditId)); + } + + var selection = new ResetSelection(creditId); + var idempotencyKey = _pendingAttempts.GetOrAdd( + selection, + static _ => Guid.NewGuid().ToString()); + var parameters = new Dictionary + { + ["idempotencyKey"] = idempotencyKey + }; + if (creditId is not null) + { + parameters["creditId"] = creditId; + } + + var result = await session.RequestAsync( + "account/rateLimitResetCredit/consume", + parameters, + cancellationToken) + .ConfigureAwait(false); + + var outcome = result.GetProperty("outcome").GetString() switch + { + "reset" => RateLimitResetOutcome.Reset, + "alreadyRedeemed" => RateLimitResetOutcome.AlreadyRedeemed, + "nothingToReset" => RateLimitResetOutcome.NothingToReset, + "noCredit" => RateLimitResetOutcome.NoCredit, + var unknownOutcome => throw new InvalidOperationException( + $"Unknown rate-limit reset outcome: {unknownOutcome ?? ""}.") + }; + _pendingAttempts.TryRemove(selection, out _); + return outcome; + } + + private readonly record struct ResetSelection(string? CreditId); +} diff --git a/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs b/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs index 37f6092..d8a1a06 100644 --- a/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs +++ b/src/CodexUsageWidget/Infrastructure/Preview/PreviewUsageProvider.cs @@ -3,10 +3,12 @@ namespace CodexUsageWidget.Infrastructure.Preview; -public sealed class PreviewUsageProvider : IUsageProvider +public sealed class PreviewUsageProvider : IUsageProvider, IRateLimitResetConsumer { private readonly IUsageProvider _wrappedProvider; private readonly TimeProvider _timeProvider; + private readonly object _resetCreditsLock = new(); + private readonly List _resetCredits; private bool _disposed; public PreviewUsageProvider( @@ -15,6 +17,13 @@ public PreviewUsageProvider( { _wrappedProvider = wrappedProvider; _timeProvider = timeProvider ?? TimeProvider.System; + var now = _timeProvider.GetLocalNow(); + _resetCredits = + [ + CreateResetCredit("preview-reset-1", now, now.AddDays(7)), + CreateResetCredit("preview-reset-2", now, now.AddDays(18)), + CreateResetCredit("preview-reset-3", now, now.AddDays(32)) + ]; } public event EventHandler? RateLimitsChanged @@ -35,6 +44,14 @@ public Task ReadUsageAsync(CancellationToken cancellationToken = cancellationToken.ThrowIfCancellationRequested(); var now = _timeProvider.GetLocalNow(); + ResetCreditSummary resetCredits; + lock (_resetCreditsLock) + { + resetCredits = new ResetCreditSummary( + _resetCredits.Count, + _resetCredits.ToArray()); + } + var snapshot = new UsageSnapshot( new UsageRateLimits( [ @@ -60,13 +77,51 @@ public Task ReadUsageAsync(CancellationToken cancellationToken = SpendControlReached: null) ], PlanType: "preview", - ResetCredits: null), + ResetCredits: resetCredits), TokenActivity: null, FetchedAt: now); return Task.FromResult(snapshot); } + public Task ConsumeAsync( + string? creditId, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + cancellationToken.ThrowIfCancellationRequested(); + + lock (_resetCreditsLock) + { + var selected = creditId is null + ? _resetCredits + .OrderBy(credit => credit.ExpiresAt is null) + .ThenBy(credit => credit.ExpiresAt) + .FirstOrDefault() + : _resetCredits.FirstOrDefault(credit => + string.Equals(credit.Id, creditId, StringComparison.Ordinal)); + if (selected is null) + { + return Task.FromResult(RateLimitResetOutcome.NoCredit); + } + + _resetCredits.Remove(selected); + } + + return Task.FromResult(RateLimitResetOutcome.Reset); + } + + private static RateLimitResetCredit CreateResetCredit( + string id, + DateTimeOffset grantedAt, + DateTimeOffset expiresAt) => new( + id, + "available", + grantedAt, + expiresAt, + "Rate-limit reset", + "Reset an eligible Codex rate-limit window."); + public ValueTask DisposeAsync() { if (_disposed) diff --git a/src/CodexUsageWidget/Resources/Strings.resx b/src/CodexUsageWidget/Resources/Strings.resx index 6fb1883..71b6f42 100644 --- a/src/CodexUsageWidget/Resources/Strings.resx +++ b/src/CodexUsageWidget/Resources/Strings.resx @@ -63,6 +63,20 @@ Spend limit resets Rate-limit resets {0} available + Next reset expires {0:MMM d 'at' h:mm tt} + Expires {0:MMM d 'at' h:mm tt} + Expiration unavailable + Use reset + Use next reset + View available resets + Hide available resets + Use this rate-limit reset? + This reset expires {0:MMM d 'at' h:mm tt}. It will be applied to an eligible Codex limit and cannot be recovered. + This reset will be applied to an eligible Codex limit and cannot be recovered. + Codex will select the next available reset. It will be applied to an eligible Codex limit and cannot be recovered. + No eligible Codex limit can be reset right now. Your reset was not used. + This rate-limit reset is no longer available. + The rate-limit reset could not be used. {0} OVERVIEW CREDITS & LIMITS TOKEN ACTIVITY diff --git a/src/CodexUsageWidget/Resources/Strings.zh-CN.resx b/src/CodexUsageWidget/Resources/Strings.zh-CN.resx index 51eeff0..ebfb261 100644 --- a/src/CodexUsageWidget/Resources/Strings.zh-CN.resx +++ b/src/CodexUsageWidget/Resources/Strings.zh-CN.resx @@ -63,6 +63,20 @@ 消费限制重置时间 用量限制重置次数 可用 {0} + 下一个重置将于 {0:M月d日 tt h:mm} 到期 + 到期时间:{0:M月d日 tt h:mm} + 到期时间不可用 + 使用重置 + 使用下一个重置 + 查看可用重置 + 隐藏可用重置 + 使用此用量限制重置? + 此重置将于 {0:M月d日 tt h:mm} 到期。它将应用于符合条件的 Codex 用量限制,使用后无法恢复。 + 此重置将应用于符合条件的 Codex 用量限制,使用后无法恢复。 + Codex 将选择下一个可用重置,并将其应用于符合条件的用量限制。使用后无法恢复。 + 目前没有可重置的符合条件的 Codex 用量限制。你的重置尚未使用。 + 此用量限制重置已不可用。 + 无法使用用量限制重置:{0} 概览 额度与限制 TOKEN 活动 diff --git a/src/CodexUsageWidget/Views/Controls/DetailedUsageView.xaml b/src/CodexUsageWidget/Views/Controls/DetailedUsageView.xaml index 063cef1..29d054c 100644 --- a/src/CodexUsageWidget/Views/Controls/DetailedUsageView.xaml +++ b/src/CodexUsageWidget/Views/Controls/DetailedUsageView.xaml @@ -3,6 +3,148 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:CodexUsageWidget.Views.Controls" xmlns:localization="clr-namespace:CodexUsageWidget.Views.Localization"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +