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
6 changes: 5 additions & 1 deletion docs/commands/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/dynamic-columns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
11 changes: 9 additions & 2 deletions docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/).
5 changes: 5 additions & 0 deletions docs/json-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
16 changes: 12 additions & 4 deletions src/cli/table_layout.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
16 changes: 15 additions & 1 deletion src/registry/account_ops.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/registry/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 12 additions & 12 deletions src/tui/rate_limit.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
}
63 changes: 63 additions & 0 deletions tests/registry_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions tests/table_layout_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
81 changes: 79 additions & 2 deletions tests/tui_table_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -105,7 +105,84 @@ 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 "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, &reg, "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, &reg, 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" {
Expand Down
Loading