Skip to content
Merged
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
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ 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
- Windows regional, 24-hour, and 12-hour time formats selected from Settings
- A movable, always-on-top desktop widget and a compact label beside the notification area
- Live task activity dots based on official local Codex lifecycle hooks
- Automatic refresh every two minutes plus live rate-limit notifications
Expand All @@ -66,13 +67,17 @@ 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
tray icon to refresh, change the display mode, open Settings, check for updates, or exit.
The Settings window opens from the gear button on the widget too. Language, theme, accent
color, widget layout, displayed limit, and Start with Windows changes apply as soon as they
are selected.
The Settings window opens from the gear button on the widget too. Language, time format,
theme, accent color, widget layout, displayed limit, and Start with Windows changes apply as
soon as they are selected.

## Activity dots

Expand Down Expand Up @@ -106,6 +111,9 @@ The application writes only under `%LOCALAPPDATA%\CodexUsageWidget`:
- `theme.txt`: system, light, or dark theme preference
- `accent-palette.txt`: selected preset accent color
- `language.txt`: system, English, or Simplified Chinese language preference
- `time-format.txt`: Windows regional, 24-hour, or 12-hour time preference
- `pending-rate-limit-reset.json`: an unfinished reset attempt kept until Codex returns a
definitive outcome, so a retry cannot consume another reset
- `logs\codex-usage-widget-YYYYMMDD.log`: diagnostic logs retained for 14 days

The widget displays ChatGPT and Codex subscription limits. It does not display OpenAI API
Expand Down
11 changes: 9 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ Codex Usage Widget 是一款在 Windows 本地运行的小组件,通过官方

- 显示各个通用 Codex 用量周期的剩余百分比和重置时间
- 主窗口、任务栏标签和托盘图标共用一个可选择的用量限制
- 可在设置或快捷展开按钮中切换紧凑和详情布局;详情布局会显示额度、消费限制、可用重置次数
- 可在设置或快捷展开按钮中切换紧凑和详情布局;详情布局会显示额度、消费限制、可用重置的到期时间与使用入口
Token 活动以及 Codex 返回的模型专属限制
- 可在设置中选择跟随系统、浅色或深色主题,并提供五种预设强调色
- 自动检测 Windows 显示语言,不支持的语言回退到英语;也可在设置中固定使用英语或简体中文
- 可在设置中选择 Windows 区域、24 小时制或 12 小时制时间格式
- 可移动、始终置顶的桌面小组件,以及位于 Windows 通知区域旁的紧凑任务栏标签
- 根据官方 Codex 本地生命周期钩子显示实时运行状态点
- 每两分钟自动刷新,并接收实时用量限制通知
Expand All @@ -57,10 +58,13 @@ Codex Usage Widget 是一款在 Windows 本地运行的小组件,通过官方

Token 活动仅供参考。Token 数量与订阅用量的剩余百分比并不直接对应。

当有可用的用量限制重置时,可在详情模式中展开**用量限制重置次数**查看到期时间。选择**使用重置**后,
小组件始终要求确认,再使用所选重置,并由 Codex 将其应用到符合条件的用量限制。

![Codex Usage Widget 任务栏标签预览](docs/images/taskbar-label.png)

点击 `−` 按钮可将小组件移至任务栏。右键单击任务栏标签或托盘图标,可以刷新、切换显示模式、
打开设置、检查更新或退出。也可以通过小组件上的齿轮按钮打开设置。语言、主题、强调色、小组件布局、
打开设置、检查更新或退出。也可以通过小组件上的齿轮按钮打开设置。语言、时间格式、主题、强调色、小组件布局、
显示的用量限制和开机启动设置会在选择后立即生效。

## 运行状态点
Expand Down Expand Up @@ -92,6 +96,9 @@ Token 活动仅供参考。Token 数量与订阅用量的剩余百分比并不
- `theme.txt`:跟随系统、浅色或深色主题偏好
- `accent-palette.txt`:所选的预设强调色
- `language.txt`:跟随系统、英语或简体中文语言偏好
- `time-format.txt`:Windows 区域、24 小时制或 12 小时制时间偏好
- `pending-rate-limit-reset.json`:在 Codex 返回明确结果前保存未完成的重置尝试,
防止重试时使用另一个重置
- `logs\codex-usage-widget-YYYYMMDD.log`:诊断日志,保留 14 天

小组件显示 ChatGPT 和 Codex 订阅用量限制,不显示 OpenAI API 账单或 API Key 用量。
Expand Down
41 changes: 27 additions & 14 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ src/CodexUsageWidget/
├── Infrastructure/
│ ├── Codex/ App-server integration plus lifecycle-hook parsing and local IPC
│ ├── Logging/ Local file diagnostics
│ ├── Settings/ Persistent display preference
│ ├── Settings/ Persistent preferences and pending reset attempts
│ └── Windows/ Tray icon and taskbar Win32 integration
└── Views/ WPF shell, presentation models and focused controls
tests/CodexUsageWidget.Tests/ Unit tests for parsing, formatting and persistence
Expand All @@ -27,27 +27,34 @@ 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. `RateLimitResetUseCase` coordinates explicit redemption, normalizes failures for the view,
and waits for a fresh usage read after every definitive outcome.
5. `CodexRateLimitResetConsumer` owns the app-server request while
`RateLimitResetAttemptStore` durably keeps one idempotency key for retries until the server
returns a definitive outcome.
6. `CodexAppServerSession` owns initialized app-server connection lifetime.
7. `JsonRpcConnection` owns stdin/stdout request correlation and process lifetime.
8. Endpoint-specific parsers convert Codex payloads into domain records.
9. 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
10. `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
11. `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
12. `UsageWidgetViewModel` maps snapshots to immutable presentation state.
13. `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
14. `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
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.
15. `TimeTextFormatter` applies the saved Windows regional, 24-hour, or 12-hour clock
preference to user-visible times while protocol and diagnostic timestamps remain unchanged.
16. `MainWindow` remains a window-lifecycle shell while the Settings window coordinates
activity-hook setup plus immediate theme, accent, time-format, widget-layout,
displayed-limit, and Windows startup preferences. Focused user controls render compact,
detailed, and repeated limit-row content.

## Dependency direction

Expand All @@ -63,6 +70,12 @@ 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.
- Redemption writes its idempotency key before contacting Codex, preserves it across app
restarts after an uncertain response, and removes it only after a definitive outcome.
- Post-redemption refresh waits behind an active usage read instead of discarding the request,
so the view cannot offer a consumed credit again from a stale snapshot.
- 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
Expand Down
13 changes: 11 additions & 2 deletions src/CodexUsageWidget/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,24 @@ protected override void OnStartup(StartupEventArgs e)
var appServerSession = new CodexAppServerSession();
var codexUsageProvider = new CodexUsageProvider(appServerSession);
IUsageProvider usageProvider = codexUsageProvider;
IRateLimitResetConsumer resetConsumer =
new CodexRateLimitResetConsumer(
appServerSession,
new RateLimitResetAttemptStore());
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
var usageMonitor = new UsageMonitor(usageProvider);
usageMonitor.DiagnosticMessage += (_, message) => _logger.Info(message);
var resetUseCase = new RateLimitResetUseCase(resetConsumer, usageMonitor);

activityMonitor = new CodexActivityMonitor(new CodexActivityPipeSignalSource());
activityMonitor.DiagnosticMessage += (_, message) => _logger.Info(message);
Expand All @@ -89,6 +96,7 @@ protected override void OnStartup(StartupEventArgs e)

var window = new MainWindow(
usageMonitor,
resetUseCase,
activityMonitor,
activityHookSetupService,
new CodexCliLauncher(),
Expand All @@ -98,7 +106,8 @@ protected override void OnStartup(StartupEventArgs e)
startupRegistrationService,
new TrayIconService(),
_themeController,
_languageController);
_languageController,
new TimeFormatPreferenceStore());
MainWindow = window;
activityMonitor.StartAsync().GetAwaiter().GetResult();
window.Show();
Expand Down
8 changes: 8 additions & 0 deletions src/CodexUsageWidget/Application/IRateLimitResetConsumer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CodexUsageWidget.Application;

public interface IRateLimitResetConsumer
{
Task<RateLimitResetOutcome> ConsumeAsync(
string? creditId,
CancellationToken cancellationToken = default);
}
9 changes: 9 additions & 0 deletions src/CodexUsageWidget/Application/RateLimitResetOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace CodexUsageWidget.Application;

public enum RateLimitResetOutcome
{
Reset,
AlreadyRedeemed,
NothingToReset,
NoCredit
}
54 changes: 54 additions & 0 deletions src/CodexUsageWidget/Application/RateLimitResetUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace CodexUsageWidget.Application;

public sealed class RateLimitResetUseCase
{
private readonly IRateLimitResetConsumer _consumer;
private readonly UsageMonitor _usageMonitor;
private readonly TimeSpan _requestTimeout;

public RateLimitResetUseCase(
IRateLimitResetConsumer consumer,
UsageMonitor usageMonitor,
TimeSpan? requestTimeout = null)
{
_consumer = consumer;
_usageMonitor = usageMonitor;
_requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(12);
}

public async Task<RateLimitResetUseResult> UseAsync(
string? creditId,
CancellationToken cancellationToken = default)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeout.CancelAfter(_requestTimeout);
var outcome = await _consumer.ConsumeAsync(creditId, timeout.Token)
.ConfigureAwait(false);

await _usageMonitor.RefreshAfterCurrentAsync(cancellationToken)
.ConfigureAwait(false);
return new RateLimitResetUseResult(outcome switch
{
RateLimitResetOutcome.Reset => RateLimitResetUseStatus.Reset,
RateLimitResetOutcome.AlreadyRedeemed => RateLimitResetUseStatus.AlreadyRedeemed,
RateLimitResetOutcome.NothingToReset => RateLimitResetUseStatus.NothingToReset,
RateLimitResetOutcome.NoCredit => RateLimitResetUseStatus.NoCredit,
_ => throw new InvalidOperationException(
$"Unknown rate-limit reset outcome: {outcome}.")
});
}
catch (OperationCanceledException)
{
return new RateLimitResetUseResult(RateLimitResetUseStatus.TimedOut);
}
catch (Exception ex)
{
return new RateLimitResetUseResult(
RateLimitResetUseStatus.Failed,
ex.Message);
}
}
}
15 changes: 15 additions & 0 deletions src/CodexUsageWidget/Application/RateLimitResetUseResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace CodexUsageWidget.Application;

public enum RateLimitResetUseStatus
{
Reset,
AlreadyRedeemed,
NothingToReset,
NoCredit,
TimedOut,
Failed
}

public sealed record RateLimitResetUseResult(
RateLimitResetUseStatus Status,
string? ErrorMessage = null);
8 changes: 8 additions & 0 deletions src/CodexUsageWidget/Application/TimeFormatPreference.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace CodexUsageWidget.Application;

public enum TimeFormatPreference
{
Automatic,
TwentyFourHour,
TwelveHour
}
67 changes: 67 additions & 0 deletions src/CodexUsageWidget/Application/TimeTextFormatter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Globalization;
using CodexUsageWidget.Localization;

namespace CodexUsageWidget.Application;

public static class TimeTextFormatter
{
public static string FormatTime(
DateTimeOffset value,
TimeFormatPreference preference = TimeFormatPreference.Automatic,
CultureInfo? culture = null,
CultureInfo? windowsRegionalCulture = null)
{
if (preference == TimeFormatPreference.TwelveHour)
{
return FormatTwelveHour(value, includeSeconds: false, culture);
}

var format = preference == TimeFormatPreference.TwentyFourHour ? "HH:mm" : "t";
var effectiveCulture = preference == TimeFormatPreference.Automatic
? windowsRegionalCulture ?? Strings.Current.WindowsRegionalCulture
: culture ?? CultureInfo.CurrentCulture;
return value.ToString(format, effectiveCulture);
}

public static string FormatTimeWithSeconds(
DateTimeOffset value,
TimeFormatPreference preference = TimeFormatPreference.Automatic,
CultureInfo? culture = null,
CultureInfo? windowsRegionalCulture = null)
{
if (preference == TimeFormatPreference.TwelveHour)
{
return FormatTwelveHour(value, includeSeconds: true, culture);
}

var format = preference == TimeFormatPreference.TwentyFourHour ? "HH:mm:ss" : "T";
var effectiveCulture = preference == TimeFormatPreference.Automatic
? windowsRegionalCulture ?? Strings.Current.WindowsRegionalCulture
: culture ?? CultureInfo.CurrentCulture;
return value.ToString(format, effectiveCulture);
}

public static string FormatDayAndTime(
DateTimeOffset value,
TimeFormatPreference preference = TimeFormatPreference.Automatic,
CultureInfo? culture = null,
CultureInfo? windowsRegionalCulture = null)
{
var effectiveCulture = culture ?? CultureInfo.CurrentCulture;
return $"{value.ToString("ddd", effectiveCulture)} " +
FormatTime(value, preference, effectiveCulture, windowsRegionalCulture);
}

private static string FormatTwelveHour(
DateTimeOffset value,
bool includeSeconds,
CultureInfo? culture)
{
var effectiveCulture = culture ?? CultureInfo.CurrentCulture;
var timePattern = includeSeconds ? "h:mm:ss" : "h:mm";
var format = effectiveCulture.TwoLetterISOLanguageName == "zh"
? $"tt {timePattern}"
: $"{timePattern} tt";
return value.ToString(format, effectiveCulture);
}
}
11 changes: 11 additions & 0 deletions src/CodexUsageWidget/Application/UsageMonitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default)
return;
}

await RefreshWithGateHeldAsync(cancellationToken).ConfigureAwait(false);
}

public async Task RefreshAfterCurrentAsync(CancellationToken cancellationToken = default)
{
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
await RefreshWithGateHeldAsync(cancellationToken).ConfigureAwait(false);
}

private async Task RefreshWithGateHeldAsync(CancellationToken cancellationToken)
{
try
{
RefreshStarted?.Invoke();
Expand Down
Loading