From ad43185cc7252f1874aaf6983f6757b00edc2f7a Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 14 Nov 2026 13:25:29 +0800 Subject: [PATCH 1/2] fix: render last-measured usage for stale reset windows Usage cells reused the auto-switch inference that treats an elapsed resets_at as a refilled quota, so stale snapshots displayed a fake 100%. The inference stays scoped to decision code (remainingPercentAt with a resets_at > 0 bound); display cells now show the last-measured remaining percent via measuredRemainingPercent and mark missing, invalid, or elapsed reset timestamps as stale instead of a reset time or dash. --- docs/commands/list.md | 6 +++- docs/implement.md | 11 +++++-- docs/json-api.md | 5 +++ src/registry/account_ops.zig | 16 ++++++++- src/registry/root.zig | 1 + src/tui/rate_limit.zig | 24 +++++++------- tests/registry_test.zig | 63 ++++++++++++++++++++++++++++++++++++ tests/tui_table_test.zig | 43 ++++++++++++++++++++++-- 8 files changed, 151 insertions(+), 18 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index cdfb08fc..c6734e0a 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -60,7 +60,11 @@ When local-only refresh is active, only the active account can be updated from l - Singleton rows with aliases render as `alias(email)`. - Singleton rows with both alias and account name render as `alias(account name, email)`. - Grouped rows keep the shared email in the header; child rows with both alias and account name render as `alias(account name)`. -- Usage cells show remaining percent and reset time when that data is known. +- Usage cells show the last measured remaining percent, plus the reset time + when a usable future reset timestamp is known. A snapshot whose reset + timestamp is missing, invalid, or already elapsed renders the measured + percent with a stale marker instead of a reset time or an inferred full + quota; the exact marker is pinned in `tests/tui_table_test.zig`. - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. - `PLAN EXP` renders the local date from the stored `id_token` subscription claim. Missing or past timestamps render as `-` in both plain and live tables. diff --git a/docs/implement.md b/docs/implement.md index facdae17..1cff7b6d 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -117,7 +117,12 @@ When API usage refresh is disabled, local usage refresh reads Codex rollout file - Rollout events older than the current account activation time are ignored. - Each account stores its own last consumed rollout signature. - Rate limits map `window_minutes = 300` to 5h and `window_minutes = 10080` to weekly. -- Past reset timestamps render as `100%`. +- Usage cells always show the last measured remaining percent; a missing, + invalid, or already-elapsed reset timestamp marks the value as stale + instead of implying a refilled quota. Auto-switch scoring keeps its own + inference: an elapsed positive reset timestamp still counts as restored + when picking accounts, and a non-positive one falls back to the measured + value. ## Display Model @@ -129,7 +134,9 @@ When API usage refresh is disabled, local usage refresh reads Codex rollout file - `PLAN` comes from the auth claim when available, then falls back to usage snapshot plan data. - `PLAN EXPIRE` comes from the stored `id_token` subscription claim. Missing or already-expired timestamps render as `-`; the column is omitted on narrow terminals. -- Usage cells show remaining percentage plus reset time when reset data is known. +- Usage cells show the measured remaining percentage plus reset time when a + usable future reset timestamp is known; otherwise the measured value is + marked stale. - `LAST ACTIVITY` is rendered as relative time from `last_usage_at`. Command-specific display behavior lives in the relevant file under [docs/commands/](./commands/). diff --git a/docs/json-api.md b/docs/json-api.md index c574400f..2c3c0741 100644 --- a/docs/json-api.md +++ b/docs/json-api.md @@ -135,6 +135,11 @@ Snapshot fields remain present after refresh failure. `updated_at` is the stored snapshot update timestamp and may remain unchanged after an equal successful response. +`used_percent` is the last-measured point value stored in the snapshot; it is +not recomputed for display. Once `resets_at` has elapsed the snapshot is +stale: the CLI's own usage cells mark such rows stale instead of assuming a +refilled quota, and consumers decide their own display policy. + `usage.refresh` describes only the current invocation: | Field | Values | diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index 286a1ed6..8c03d3bd 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -440,11 +440,25 @@ pub fn usageScoreAt(usage: ?RateLimitSnapshot, now: i64) ?i64 { return null; } +/// Decision-facing remaining percent. An elapsed positive `resets_at` counts +/// as fully restored: an optimistic inference kept for auto-switch scoring so +/// an account whose window ended is treated as usable again. It is not a +/// measurement — display paths must use `measuredRemainingPercent` instead. +/// `resets_at <= 0` is not a real timestamp and falls through to the measured +/// value like a missing `resets_at`. pub fn remainingPercentAt(window: ?RateLimitWindow, now: i64) ?i64 { if (window == null) return null; if (window.?.resets_at) |resets_at| { - if (resets_at <= now) return 100; + if (resets_at > 0 and resets_at <= now) return 100; } + return measuredRemainingPercent(window); +} + +/// Last-measured remaining percent: `100 - used_percent` clamped to +/// `0...100`, with no reset-window inference. `null` only when there is no +/// window snapshot at all. +pub fn measuredRemainingPercent(window: ?RateLimitWindow) ?i64 { + if (window == null) return null; const remaining = 100.0 - window.?.used_percent; if (remaining <= 0.0) return 0; if (remaining >= 100.0) return 100; diff --git a/src/registry/root.zig b/src/registry/root.zig index 7b6ff8c6..f82c71f0 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -106,6 +106,7 @@ pub const removeAccounts = account_ops.removeAccounts; pub const selectBestAccountIndexByUsage = account_ops.selectBestAccountIndexByUsage; pub const usageScoreAt = account_ops.usageScoreAt; pub const remainingPercentAt = account_ops.remainingPercentAt; +pub const measuredRemainingPercent = account_ops.measuredRemainingPercent; pub const resolveRateWindow = account_ops.resolveRateWindow; pub const shouldFetchWorkspaceAccountNamesForUser = account_ops.shouldFetchWorkspaceAccountNamesForUser; pub const activeChatgptUserId = account_ops.activeChatgptUserId; diff --git a/src/tui/rate_limit.zig b/src/tui/rate_limit.zig index e9fea646..b918c9f8 100644 --- a/src/tui/rate_limit.zig +++ b/src/tui/rate_limit.zig @@ -18,15 +18,21 @@ const ResetParts = struct { /// the TUI call sites on the same resolution without a second implementation. pub const resolveRateWindow = registry.resolveRateWindow; +/// Display-facing usage cell. Only the last-measured remaining percent is +/// shown: the "elapsed `resets_at` means refilled" inference in +/// `registry.remainingPercentAt` exists for auto-switch scoring and must not +/// turn a stale snapshot into a fake full quota. Without a real future +/// `resets_at` (missing, `<= 0`, or already elapsed) the cell shows the +/// measured percent plus a stale marker instead of a reset time. pub fn formatRateLimitFullAlloc(allocator: std.mem.Allocator, window: ?registry.RateLimitWindow) ![]u8 { - if (window == null) return try allocator.print("-", .{}); - if (window.?.resets_at == null) return try allocator.print("-", .{}); + const remaining = registry.measuredRemainingPercent(window) orelse + return try allocator.print("-", .{}); const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - const reset_at = window.?.resets_at.?; - if (now >= reset_at) { - return try allocator.print("100%", .{}); + const reset_at = window.?.resets_at orelse + return allocator.print("{d}% (stale)", .{remaining}); + if (reset_at <= 0 or now >= reset_at) { + return allocator.print("{d}% (stale)", .{remaining}); } - const remaining = remainingPercent(window.?.used_percent); var parts = try resetPartsAlloc(allocator, reset_at, now); defer parts.deinit(allocator); if (parts.same_day) { @@ -79,9 +85,3 @@ fn resetPartsAlloc(allocator: std.mem.Allocator, reset_at: i64, now: i64) !Reset }; } -fn remainingPercent(used: f64) i64 { - const remaining = 100.0 - used; - if (remaining <= 0.0) return 0; - if (remaining >= 100.0) return 100; - return @as(i64, @intFromFloat(remaining)); -} diff --git a/tests/registry_test.zig b/tests/registry_test.zig index 558ae272..75fa7582 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -421,6 +421,69 @@ test "plan labels are human-readable while registry stores canonical plan values try std.testing.expectEqualStrings("Academic Researchers Plus", registry.planLabel(.sci_plus)); } +test "remainingPercentAt keeps the elapsed-window inference for decisions" { + const now: i64 = 1_800_000_000; + const window = registry.RateLimitWindow{ + .used_percent = 40.0, + .window_minutes = 300, + .resets_at = now - 60, + }; + // Auto-switch scoring still treats an elapsed positive resets_at as + // refilled; the display layer does not consume this inference. + try std.testing.expectEqual(@as(?i64, 100), registry.remainingPercentAt(window, now)); + + const pending = registry.RateLimitWindow{ + .used_percent = 40.0, + .window_minutes = 300, + .resets_at = now + 60, + }; + try std.testing.expectEqual(@as(?i64, 60), registry.remainingPercentAt(pending, now)); +} + +test "remainingPercentAt treats a non-positive resets_at as measured data" { + const now: i64 = 1_800_000_000; + for ([_]i64{ 0, -5000 }) |resets_at| { + const exhausted = registry.RateLimitWindow{ + .used_percent = 100.0, + .window_minutes = 300, + .resets_at = resets_at, + }; + try std.testing.expectEqual(@as(?i64, 0), registry.remainingPercentAt(exhausted, now)); + + const partial = registry.RateLimitWindow{ + .used_percent = 30.0, + .window_minutes = 300, + .resets_at = resets_at, + }; + try std.testing.expectEqual(@as(?i64, 70), registry.remainingPercentAt(partial, now)); + } +} + +test "measuredRemainingPercent reports the stored point value without reset inference" { + try std.testing.expectEqual(@as(?i64, null), registry.measuredRemainingPercent(null)); + + const window = registry.RateLimitWindow{ + .used_percent = 12.5, + .window_minutes = 300, + .resets_at = null, + }; + try std.testing.expectEqual(@as(?i64, 87), registry.measuredRemainingPercent(window)); + + const elapsed = registry.RateLimitWindow{ + .used_percent = 40.0, + .window_minutes = 300, + .resets_at = 1, + }; + try std.testing.expectEqual(@as(?i64, 60), registry.measuredRemainingPercent(elapsed)); + + const over_used = registry.RateLimitWindow{ + .used_percent = 140.0, + .window_minutes = 300, + .resets_at = null, + }; + try std.testing.expectEqual(@as(?i64, 0), registry.measuredRemainingPercent(over_used)); +} + test "resolveDisplayPlan prefers a usage snapshot plan over the stored auth plan" { const gpa = std.testing.allocator; var reg = makeEmptyRegistry(); diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 287a3ce4..f59a4075 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -94,7 +94,7 @@ test "writeTruncatedPadded preserves UTF-8 codepoint boundaries" { try std.testing.expect(std.unicode.utf8ValidateSlice(writer.buffered())); } -test "formatRateLimitFullAlloc shows 100% after reset instead of dash-prefixed value" { +test "formatRateLimitFullAlloc shows measured remaining plus a stale marker after reset elapsed" { const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); const window = registry.RateLimitWindow{ .used_percent = 100.0, @@ -105,7 +105,46 @@ test "formatRateLimitFullAlloc shows 100% after reset instead of dash-prefixed v const formatted = try formatRateLimitFullAlloc(std.heap.page_allocator, window); defer std.heap.page_allocator.free(formatted); - try std.testing.expectEqualStrings("100%", formatted); + // The elapsed reset window must not impersonate a refilled quota: the + // cell shows the last measured remaining percent, marked stale. + try std.testing.expectEqualStrings("0% (stale)", formatted); +} + +test "formatRateLimitFullAlloc marks usage stale when resets_at is missing or invalid" { + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + for ([_]?i64{ null, 0, -5000, now - 1 }) |resets_at| { + const window = registry.RateLimitWindow{ + .used_percent = 25.0, + .window_minutes = 300, + .resets_at = resets_at, + }; + const formatted = try formatRateLimitFullAlloc(std.heap.page_allocator, window); + defer std.heap.page_allocator.free(formatted); + try std.testing.expectEqualStrings("75% (stale)", formatted); + } +} + +test "formatRateLimitFullAlloc shows remaining and reset time only for a future reset" { + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + const window = registry.RateLimitWindow{ + .used_percent = 25.0, + .window_minutes = 300, + .resets_at = now + 3600, + }; + + const formatted = try formatRateLimitFullAlloc(std.heap.page_allocator, window); + defer std.heap.page_allocator.free(formatted); + + try std.testing.expect(std.mem.startsWith(u8, formatted, "75% (")); + try std.testing.expect(std.mem.endsWith(u8, formatted, ")")); + try std.testing.expect(std.mem.find(u8, formatted, "stale") == null); +} + +test "formatRateLimitFullAlloc renders a dash when no window snapshot exists" { + const formatted = try formatRateLimitFullAlloc(std.heap.page_allocator, null); + defer std.heap.page_allocator.free(formatted); + + try std.testing.expectEqualStrings("-", formatted); } test "writeAccountsTable shows right-aligned row numbers for selectable accounts" { From 06b09bedd33efdc753d58f887f0f83c8deaf4cf3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sat, 14 Nov 2026 13:30:19 +0800 Subject: [PATCH 2/2] fix: keep a compact stale flag on narrow usage cells Narrow columns shortened N% (stale) to a bare N%, which passed for a live reading. The stale marker now collapses to N%! instead of shedding outright, pinned by a cell-writer test and a table width sweep. --- docs/dynamic-columns.md | 2 +- src/cli/table_layout.zig | 16 ++++++++++++---- tests/table_layout_test.zig | 28 +++++++++++++++++++++++++++ tests/tui_table_test.zig | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md index fae38a07..a472dbe4 100644 --- a/docs/dynamic-columns.md +++ b/docs/dynamic-columns.md @@ -158,7 +158,7 @@ prefix, so a narrow column never shows a mid-word cut: | `LAST` | `Nd ago`/`Nh ago` drops ` ago` before truncating | | `CREDITS` | `unlimited` becomes `inf` below its natural width | | `RESETS` | `N (exp MM-DD)` degrades to the bare count `N` | -| `5H`, `WEEKLY` | `N% (HH:MM on D Mon)` drops the clock (`N% (D Mon)`), then the parenthetical (`N%`) | +| `5H`, `WEEKLY` | `N% (HH:MM on D Mon)` drops the clock (`N% (D Mon)`), then the parenthetical (`N%`); a stale marker collapses to a compact flag instead of a bare `N%` | | `ACCOUNT` | prefix + `.` + suffix truncation (`very-l.ple.com`) once the column is wide enough for a useful suffix; see [table-layout.md](table-layout.md) | | everything | `prefix.` truncation, display-width and UTF-8 safe | diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 4ebc5e2c..a5996db6 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -300,9 +300,11 @@ fn writeTruncated(out: *std.Io.Writer, text: []const u8, width: usize) !usize { return text_width.displayWidth(prefix) + 1; } -/// Usage cells read `N% (HH:MM on D Mon)`, `N% (HH:MM)` or a bare label such -/// as `FileNotFound`. Narrow widths first drop the clock part -/// (`N% (D Mon)`), then the parenthetical entirely (`N%`). +/// Usage cells read `N% (HH:MM on D Mon)`, `N% (HH:MM)`, `N% (stale)` or a +/// bare label such as `FileNotFound`. Narrow widths first drop the clock +/// part (`N% (D Mon)`), then the parenthetical entirely (`N%`). A stale +/// marker never sheds outright — it collapses to `N%!` — so aged data +/// cannot pass for a live reading at narrow widths. fn writeUsageCell(out: *std.Io.Writer, text: []const u8, width: usize) !usize { const measured = text_width.displayWidth(text); if (measured <= width) { @@ -325,7 +327,13 @@ fn writeUsageCell(out: *std.Io.Writer, text: []const u8, width: usize) !usize { } } const head_w = text_width.displayWidth(text[0..paren]); - if (head_w <= width) { + if (std.mem.eql(u8, text[paren..], " (stale)")) { + if (head_w + 1 <= width) { + try out.writeAll(text[0..paren]); + try out.writeAll("!"); + return head_w + 1; + } + } else if (head_w <= width) { try out.writeAll(text[0..paren]); return head_w; } diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index f1954c7c..10392311 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -319,6 +319,34 @@ test "plain and live views keep shared columns in the same relative order" { } } +test "usage cell truncation keeps a stale marker instead of a bare percent" { + const table_layout = cli.table_layout; + var buffer: [64]u8 = undefined; + + var writer: std.Io.Writer = .fixed(&buffer); + _ = try table_layout.writeCellFor(.rate_5h, &writer, "75% (stale)", 11); + try std.testing.expectEqualStrings("75% (stale)", writer.buffered()); + + writer = .fixed(&buffer); + _ = try table_layout.writeCellFor(.rate_5h, &writer, "75% (stale)", 6); + try std.testing.expectEqualStrings("75%!", writer.buffered()); + + writer = .fixed(&buffer); + _ = try table_layout.writeCellFor(.rate_5h, &writer, "75% (stale)", 4); + try std.testing.expectEqualStrings("75%!", writer.buffered()); + + // Below the marker's width the cell still renders as truncated text, + // never as a clean bare percent. + writer = .fixed(&buffer); + _ = try table_layout.writeCellFor(.rate_week, &writer, "100% (stale)", 4); + try std.testing.expect(std.mem.find(u8, writer.buffered(), "100%") == null); + + // A reset-time parenthetical still sheds cleanly to the bare percent. + writer = .fixed(&buffer); + _ = try table_layout.writeCellFor(.rate_5h, &writer, "75% (21:40)", 4); + try std.testing.expectEqualStrings("75%", writer.buffered()); +} + test "account cell truncation follows the documented width table" { const table_layout = cli.table_layout; const label = "very-long-account-name@example.com"; diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index f59a4075..388ddf12 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -147,6 +147,44 @@ test "formatRateLimitFullAlloc renders a dash when no window snapshot exists" { try std.testing.expectEqualStrings("-", formatted); } +test "writeAccountsTableForCols keeps a stale marker on usage cells at every width" { + const gpa = std.testing.allocator; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user-1::acc-1", "very-long-account-name@example.com", "", .pro); + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 100, .window_minutes = 300, .resets_at = now - 60 }, + .secondary = .{ .used_percent = 30, .window_minutes = 10080, .resets_at = now - 60 }, + .credits = null, + .plan_type = .pro, + }; + + var saw_full_marker = false; + var saw_compact_marker = false; + var width: usize = 5; + while (width <= 130) : (width += 1) { + var buffer: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, width); + const output = writer.buffered(); + + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + try std.testing.expect(line.len <= width); + // A bare percent (cell boundary or end of line) must never render: + // stale data always carries "(stale)", "!" or a truncation mark. + try std.testing.expect(std.mem.find(u8, line, "% ") == null); + try std.testing.expect(!std.mem.endsWith(u8, line, "%")); + } + if (std.mem.find(u8, output, "(stale)") != null) saw_full_marker = true; + if (std.mem.find(u8, output, "%!") != null) saw_compact_marker = true; + } + try std.testing.expect(saw_full_marker); + try std.testing.expect(saw_compact_marker); +} + test "writeAccountsTable shows right-aligned row numbers for selectable accounts" { const gpa = std.testing.allocator; var reg = makeTestRegistry();