From d7235e1d3a1f41343e5bea2edb4577f0b29e96ab Mon Sep 17 00:00:00 2001 From: zfaustk <4340287+zfaustk@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:29:12 +0800 Subject: [PATCH 1/7] feat: show account lifecycle details in list --- docs/commands/list.md | 11 ++ docs/json-api.md | 19 +++ docs/schema-migration.md | 13 ++- src/api/usage.zig | 61 +++++++++- src/auth/auth.zig | 29 +++++ src/cli/json_output.zig | 43 +++++++ src/cli/list_details.zig | 203 +++++++++++++++++++++++++++++++++ src/cli/live_view.zig | 20 ++-- src/cli/render.zig | 121 ++++++++++++++++---- src/cli/rows.zig | 13 +++ src/cli/table_layout.zig | 4 +- src/registry/account_ops.zig | 10 +- src/registry/common.zig | 100 +++++++++++++++- src/registry/import.zig | 1 + src/registry/parse.zig | 100 +++++++++++++++- src/registry/root.zig | 5 + src/registry/storage.zig | 3 +- src/registry/storage_parse.zig | 1 + src/workflows/live_display.zig | 4 + src/workflows/results.zig | 33 +++++- src/workflows/usage.zig | 10 ++ tests/api_usage_test.zig | 43 +++++++ tests/auth_test.zig | 13 ++- tests/cli_picker_test.zig | 92 +++++++++++++++ tests/registry_test.zig | 10 +- tests/workflows_live_test.zig | 2 + 26 files changed, 909 insertions(+), 55 deletions(-) create mode 100644 src/cli/list_details.zig diff --git a/docs/commands/list.md b/docs/commands/list.md index 5fc7545d..8c171d93 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -26,6 +26,16 @@ codex-auth list --json - `--api` is accepted as an explicit equivalent to default mode. - `--skip-api` forbids remote API calls for this command. - `--live` keeps refreshing the terminal view and requires a TTY. +- In live tables, `*` before the number marks the active account and `!` marks a refresh error. +- Live account details show the earliest future quota reset, access-token expiry, + and the expiry date for each reset-credit card returned by the server. The live + table uses compact local timestamps and card dates; the JSON output retains the + complete card records, including status, grant date, ID, type, and description. +- Access-token expiry is shown in local time and is not the account or subscription + expiry; refreshing credentials can extend it. Plan expiry is shown as unavailable + because the current data sources do not provide it. Server card lists may be + capped; compare `available_count` with the JSON `credits` length before treating + the list as exhaustive. After a refresh failure, details may be cached. - `--json` emits one machine-readable JSON document and cannot be combined with `--live`. When local-only refresh is active, only the active account can be updated from local rollout files. Non-active rows use the stored registry snapshot. @@ -38,6 +48,7 @@ When local-only refresh is active, only the active account can be updated from l - Usage cells show remaining percent and reset time when that data is known. - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. - `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. +- JSON output includes the returned reset-credit details and `next_reset_at`; the latter is the earliest future 5-hour or weekly reset. - Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`. - `LAST ACTIVITY` is based on the last stored usage update time. - `--json` returns accounts in the same display order and includes the same row numbers shown by the table. diff --git a/docs/json-api.md b/docs/json-api.md index 1e1c8a92..e03cc530 100644 --- a/docs/json-api.md +++ b/docs/json-api.md @@ -46,6 +46,7 @@ shortcut and is rejected when combined with `--json`. "account_name": null, "plan": "business", "auth_mode": "chatgpt", + "auth_expires_at": null, "active": true, "created_at": 1730000000, "last_used_at": 1730001000, @@ -64,6 +65,8 @@ shortcut and is rejected when combined with `--json`. "balance": null }, "reset_credits": null, + "reset_credit_details": null, + "next_reset_at": 1730010000, "refresh": { "requested": true, "method": "api", @@ -75,10 +78,26 @@ shortcut and is rejected when combined with `--json`. } ``` +`reset_credits` remains the backward-compatible available-count field. +`reset_credit_details`, when present, contains the backend's returned reset +credit records, including each record's `id`, `reset_type`, `status`, +`granted_at`, optional `expires_at`, `title`, and `description`. The backend +may cap the returned list; compare `available_count` with `credits.length` +before presenting it as exhaustive. `next_reset_at` is the earliest future +reset timestamp from `primary.resets_at` and `secondary.resets_at`. + +The API currently does not expose a verifiable subscription-plan expiration +timestamp, so the JSON contract intentionally does not label token expiry as +plan expiry. + `account_key` is stable and should be used for switch/remove calls. `number` is an ephemeral display selector valid only for the ordering returned by the current invocation. Empty aliases and account names are `null`. +`auth_expires_at` is the Unix timestamp when the stored access token expires. +It describes credential lifetime only; it is not a subscription or account +entitlement expiration timestamp. + `plan` is already normalized by the CLI. Important mappings are: | Input observed by the CLI | JSON plan | diff --git a/docs/schema-migration.md b/docs/schema-migration.md index dd91c0d2..2dedf99d 100644 --- a/docs/schema-migration.md +++ b/docs/schema-migration.md @@ -10,18 +10,18 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r ## Current Policy -- The current persisted format is `schema_version = 4`. -- The compatibility guarantee for this release is migration from released schema `3` to the final schema `4` format. +- The current persisted format is `schema_version = 5`. +- The compatibility guarantee for this release is migration from released schema `4` to the final schema `5` format. - The legacy `version = 2` loader remains available, but it is not the compatibility boundary for this change. -- The current binary accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them to normalized `schema_version = 4`. +- The current binary accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them to normalized `schema_version = 5`. - If the binary sees a newer `schema_version` than it understands, it fails with `UnsupportedRegistryVersion` and must not write the file. ## Upgrade Behavior - User-visible behavior is always “upgrade directly to the latest supported schema”. - Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps. -- The guaranteed automatic migration is `schema_version = 3 -> schema_version = 4`. -- The existing legacy `version = 2` loader also rewrites directly to schema `4`. +- The guaranteed automatic migration is `schema_version = 4 -> schema_version = 5`; older supported layouts are rewritten directly to schema `5`. +- The existing legacy `version = 2` loader also rewrites directly to schema `5`. - Users are not expected to install intermediate `codex-auth` versions. ## Schema History @@ -47,6 +47,9 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - Legacy `team` becomes `business` - Legacy `business` becomes `enterprise` - The same conversion applies to per-account `last_usage.plan_type` +- `schema_version = 5` + - Stores the access-token expiry timestamp for each account when the token exposes a JWT `exp` claim + - Stores reset-credit detail records returned by the reset-credit endpoint alongside the available count ## When To Bump `schema_version` diff --git a/src/api/usage.zig b/src/api/usage.zig index 3e3a7277..c37136b5 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -2,14 +2,17 @@ const std = @import("std"); const auth = @import("../auth/auth.zig"); const chatgpt_http = @import("http.zig"); const registry = @import("../registry/root.zig"); +const registry_parse = @import("../registry/parse.zig"); pub const default_usage_endpoint = "https://chatgpt.com/backend-api/wham/usage"; +pub const default_reset_credits_endpoint = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; pub const UsageFetchResult = struct { snapshot: ?registry.RateLimitSnapshot, status_code: ?u16, error_code: ?ResponseErrorCode = null, missing_auth: bool = false, + auth_expires_at: ?i64 = null, }; pub const max_response_error_code_bytes: usize = 64; @@ -29,6 +32,7 @@ pub const BatchUsageFetchResult = struct { error_code: ?ResponseErrorCode = null, missing_auth: bool = false, error_name: ?[]const u8 = null, + auth_expires_at: ?i64 = null, pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { if (self.snapshot) |*snapshot| { @@ -74,7 +78,9 @@ pub fn fetchUsageForAuthPathDetailed(allocator: std.mem.Allocator, auth_path: [] const access_token = info.access_token orelse return .{ .snapshot = null, .status_code = null, .missing_auth = true }; const chatgpt_account_id = info.chatgpt_account_id orelse return .{ .snapshot = null, .status_code = null, .missing_auth = true }; - return try fetchUsageForTokenDetailed(allocator, default_usage_endpoint, access_token, chatgpt_account_id); + var result = try fetchUsageForTokenDetailed(allocator, default_usage_endpoint, access_token, chatgpt_account_id); + result.auth_expires_at = auth.accessTokenExpiresAt(allocator, access_token) catch null; + return result; } pub fn fetchUsageForAuthPathsDetailedBatch( @@ -104,6 +110,7 @@ pub fn fetchUsageForAuthPathsDetailedBatch( continue; }; defer info.deinit(arena); + results[idx].auth_expires_at = info.access_token_expires_at; if (info.auth_mode == .apikey) { continue; @@ -171,6 +178,32 @@ pub fn fetchUsageForAuthPathsDetailedBatch( } } + var has_snapshot = false; + for (results) |result| { + if (result.snapshot != null) { + has_snapshot = true; + break; + } + } + if (!has_snapshot) return results; + + var reset_credit_http_results = chatgpt_http.runGetJsonBatchCommand( + allocator, + default_reset_credits_endpoint, + requests.items, + max_concurrency, + ) catch return results; + defer reset_credit_http_results.deinit(allocator); + + for (request_indexes, 0..) |request_idx, result_idx| { + const unique_idx = request_idx orelse continue; + if (results[result_idx].snapshot == null) continue; + const http_result = reset_credit_http_results.items[unique_idx]; + if (http_result.outcome != .ok or http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) continue; + const details = parseResetCreditResponse(allocator, http_result.body) catch null; + if (details) |value| results[result_idx].snapshot.?.reset_credit_details = value; + } + return results; } @@ -200,13 +233,31 @@ pub fn fetchUsageForTokenDetailed( return .{ .snapshot = null, .status_code = http_result.status_code, .error_code = error_code }; } + var snapshot = try parseUsageResponse(allocator, http_result.body); + if (snapshot) |*value| { + errdefer registry.freeRateLimitSnapshot(allocator, value); + if (fetchResetCreditDetailsForToken(allocator, access_token, account_id) catch null) |details| { + value.reset_credit_details = details; + } + } return .{ - .snapshot = try parseUsageResponse(allocator, http_result.body), + .snapshot = snapshot, .status_code = http_result.status_code, .error_code = error_code, }; } +fn fetchResetCreditDetailsForToken( + allocator: std.mem.Allocator, + access_token: []const u8, + account_id: []const u8, +) !?registry.RateLimitResetCredits { + const http_result = try chatgpt_http.runGetJsonCommand(allocator, default_reset_credits_endpoint, access_token, account_id); + defer allocator.free(http_result.body); + if (http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) return null; + return try parseResetCreditResponse(allocator, http_result.body); +} + fn isNonSuccessStatus(status_code: ?u16) bool { const status = status_code orelse return false; return status < 200 or status > 299; @@ -299,6 +350,12 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi return snapshot; } +pub fn parseResetCreditResponse(allocator: std.mem.Allocator, body: []const u8) !?registry.RateLimitResetCredits { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .parse_numbers = false }); + defer parsed.deinit(); + return try registry_parse.parseResetCreditDetails(allocator, parsed.value); +} + fn parseResetCredits(v: std.json.Value) ?i64 { const obj = switch (v) { .object => |o| o, diff --git a/src/auth/auth.zig b/src/auth/auth.zig index e3ea7f75..ab21e956 100644 --- a/src/auth/auth.zig +++ b/src/auth/auth.zig @@ -10,6 +10,7 @@ pub const AuthInfo = struct { access_token: ?[]u8, openai_api_key: ?[]u8 = null, last_refresh: ?[]u8, + access_token_expires_at: ?i64 = null, plan: ?registry.PlanType, auth_mode: registry.AuthMode, @@ -90,6 +91,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .access_token = null, .openai_api_key = try allocator.dupe(u8, trimmed), .last_refresh = null, + .access_token_expires_at = null, .plan = null, .auth_mode = .apikey, }; @@ -193,6 +195,10 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn const chatgpt_account_id = try resolveChatGptAccountId(token_chatgpt_account_id, jwt_chatgpt_account_id); const chatgpt_user_id_value = chatgpt_user_id orelse return error.MissingChatgptUserId; const record_key = try recordKeyAlloc(allocator, chatgpt_user_id_value, chatgpt_account_id); + const access_token_expires_at = if (access_token) |token| + jwtExpiryClaim(allocator, token) catch null + else + null; const info = AuthInfo{ .email = email, @@ -202,6 +208,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .access_token = access_token, .openai_api_key = null, .last_refresh = last_refresh, + .access_token_expires_at = access_token_expires_at, .plan = plan, .auth_mode = .chatgpt, }; @@ -238,6 +245,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .access_token = null, .openai_api_key = null, .last_refresh = null, + .access_token_expires_at = null, .plan = null, .auth_mode = .chatgpt, }; @@ -317,6 +325,27 @@ pub fn decodeJwtPayload(allocator: std.mem.Allocator, jwt: []const u8) ![]u8 { return decoded; } +fn jwtExpiryClaim(allocator: std.mem.Allocator, jwt: []const u8) !?i64 { + const payload = try decodeJwtPayload(allocator, jwt); + defer allocator.free(payload); + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); + defer parsed.deinit(); + const obj = switch (parsed.value) { + .object => |value| value, + else => return null, + }; + const value = obj.get("exp") orelse return null; + return switch (value) { + .integer => |number| number, + .number_string, .string => |text| std.fmt.parseInt(i64, text, 10) catch null, + else => null, + }; +} + +pub fn accessTokenExpiresAt(allocator: std.mem.Allocator, jwt: []const u8) !?i64 { + return try jwtExpiryClaim(allocator, jwt); +} + fn base64UrlNoPadDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { const decoder = std.base64.url_safe_no_pad.Decoder; const out_len = decoder.calcSizeForSlice(input) catch return error.InvalidBase64; diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index 02ef96d7..92efadc9 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -166,6 +166,8 @@ fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !v try writeOptionalText(jw, planName(account.plan)); try jw.objectField("auth_mode"); try writeOptionalText(jw, authModeName(account.auth_mode)); + try jw.objectField("auth_expires_at"); + try jw.write(account.auth_expires_at); try jw.objectField("active"); try jw.write(account.active); try jw.objectField("created_at"); @@ -191,11 +193,52 @@ fn writeUsage(jw: *std.json.Stringify, usage: *const results.UsageView) !void { try writeCredits(jw, usage.credits); try jw.objectField("reset_credits"); try jw.write(usage.reset_credits); + try jw.objectField("reset_credit_details"); + try writeResetCreditDetails(jw, usage.reset_credit_details); + try jw.objectField("next_reset_at"); + try jw.write(usage.next_reset_at); try jw.objectField("refresh"); try writeUsageRefresh(jw, &usage.refresh); try jw.endObject(); } +fn writeResetCreditDetails( + jw: *std.json.Stringify, + details: ?registry.RateLimitResetCredits, +) !void { + const value = details orelse { + try jw.write(null); + return; + }; + try jw.beginObject(); + try jw.objectField("available_count"); + try jw.write(value.available_count); + try jw.objectField("total_earned_count"); + try jw.write(value.total_earned_count); + try jw.objectField("credits"); + try jw.beginArray(); + for (value.credits) |credit| { + try jw.beginObject(); + try jw.objectField("id"); + try jw.write(credit.id); + try jw.objectField("reset_type"); + try jw.write(credit.reset_type); + try jw.objectField("status"); + try jw.write(credit.status); + try jw.objectField("granted_at"); + try jw.write(credit.granted_at); + try jw.objectField("expires_at"); + try jw.write(credit.expires_at); + try jw.objectField("title"); + try jw.write(credit.title); + try jw.objectField("description"); + try jw.write(credit.description); + try jw.endObject(); + } + try jw.endArray(); + try jw.endObject(); +} + fn usageSourceName(source: results.UsageSource) []const u8 { return switch (source) { .api => "api", diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig new file mode 100644 index 00000000..738601ac --- /dev/null +++ b/src/cli/list_details.zig @@ -0,0 +1,203 @@ +const std = @import("std"); +const registry = @import("../registry/root.zig"); +const app_runtime = @import("../core/runtime.zig"); +const rows = @import("rows.zig"); +const table_layout = @import("table_layout.zig"); + +const max_columns = 64; +const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "NEXT RESET", "LAST", "ACCESS TOKEN EXP" }; +const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 16, 10, 16 }; +const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 10, 5, 10 }; +const base_column_count = base_headers.len; +const credit_width: usize = 10; +const separator_width: usize = 3; + +pub fn writeHeader(allocator: std.mem.Allocator, out: *std.Io.Writer, reg: *const registry.Registry, number_width: usize, max_cols: ?usize) !void { + const visible_base = visibleBaseColumns(max_cols, number_width); + const card_count = visibleCardCount(maxCardCount(reg), max_cols, visible_base, number_width); + var values: [max_columns][]const u8 = undefined; + var widths: [max_columns]usize = undefined; + var minimums: [max_columns]usize = undefined; + var count: usize = 0; + for (base_headers, 0..) |value, index| { + if (!visible_base[index]) continue; + values[count] = value; + widths[count] = if (index == 0) number_width else base_desired_widths[index]; + minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; + count += 1; + } + var labels: [max_columns][16]u8 = undefined; + for (0..card_count) |index| { + const label = std.fmt.bufPrint(&labels[index], "C{d} EXP", .{index + 1}) catch "C EXP"; + values[count] = label; + widths[count] = credit_width; + minimums[count] = credit_width; + count += 1; + } + try writeAlignedRow(out, values[0..count], widths[0..count], minimums[0..count], max_cols, false); + _ = allocator; +} + +pub fn write( + allocator: std.mem.Allocator, + out: *std.Io.Writer, + account: registry.AccountRecord, + row: anytype, + number_width: usize, + max_cards: usize, + max_cols: ?usize, +) !void { + const visible_base = visibleBaseColumns(max_cols, number_width); + const card_count = visibleCardCount(max_cards, max_cols, visible_base, number_width); + const next_reset = try nextResetText(allocator, account.last_usage); + defer allocator.free(next_reset); + const token_expiry = try rows.formatTimestampAlloc(allocator, account.auth_expires_at); + defer allocator.free(token_expiry); + + const number = row.number; + var values: [max_columns][]const u8 = undefined; + var widths: [max_columns]usize = undefined; + var minimums: [max_columns]usize = undefined; + var count: usize = 0; + const base_values = [_][]const u8{ number, row.account, row.plan, row.rate_5h, row.rate_week, next_reset, row.last, token_expiry }; + for (base_values, 0..) |value, index| { + if (!visible_base[index]) continue; + values[count] = value; + widths[count] = if (index == 0) number_width else base_desired_widths[index]; + minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; + count += 1; + } + for (0..card_count) |index| { + values[count] = creditExpiry(account.last_usage, index) orelse "-"; + widths[count] = credit_width; + minimums[count] = credit_width; + count += 1; + } + try writeAlignedRow(out, values[0..count], widths[0..count], minimums[0..count], max_cols, true); +} + +fn maxCardCount(reg: *const registry.Registry) usize { + var result: usize = 0; + for (reg.accounts.items) |account| { + if (account.last_usage) |usage| { + if (usage.reset_credit_details) |details| result = @max(result, details.credits.len); + } + } + return result; +} + +fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count]bool { + var visible: [base_column_count]bool = undefined; + for (&visible) |*value| value.* = true; + const limit = max_cols orelse return visible; + const drop_order = [_]usize{ 7, 6, 5, 2 }; + while (minimumTableWidth(visible, 0, number_width) > limit) { + var changed = false; + for (drop_order) |index| { + if (visible[index]) { + visible[index] = false; + changed = true; + break; + } + } + if (!changed) break; + } + return visible; +} + +fn visibleCardCount(max_cards: usize, max_cols: ?usize, visible_base: [base_column_count]bool, number_width: usize) usize { + var count = @min(max_cards, max_columns - base_column_count); + const limit = max_cols orelse return count; + while (count > 0 and minimumTableWidth(visible_base, count, number_width) > limit) : (count -= 1) {} + return count; +} + +fn minimumTableWidth(visible_base: [base_column_count]bool, card_count: usize, number_width: usize) usize { + var total: usize = 0; + var column_count: usize = 0; + for (base_minimum_widths, 0..) |width, index| { + if (!visible_base[index]) continue; + total += if (index == 0) @max(width, number_width) else width; + column_count += 1; + } + total += card_count * credit_width; + column_count += card_count; + return if (column_count == 0) 0 else total + (column_count - 1) * separator_width; +} + +fn writeAlignedRow( + out: *std.Io.Writer, + values: []const []const u8, + desired: []const usize, + minimums: []const usize, + max_cols: ?usize, + account_column: bool, +) !void { + var widths: [max_columns]usize = undefined; + for (desired, 0..) |width, index| widths[index] = width; + if (max_cols) |limit| { + var total = minimumForWidths(widths[0..values.len]); + while (total > limit) { + var changed = false; + var index = values.len; + while (index > 0) { + index -= 1; + if (widths[index] > minimums[index]) { + widths[index] -= 1; + total -= 1; + changed = true; + break; + } + } + if (changed) continue; + + // If the required columns still do not fit, keep the frame inside + // the terminal rather than allowing a hard wrap. + for (widths[0..values.len]) |*width| { + if (total <= limit) break; + if (width.* > 1) { + width.* -= 1; + total -= 1; + } + } + if (total > limit) break; + } + } + for (values, 0..) |value, index| { + if (index != 0) try out.writeAll(" | "); + if (account_column and index == 1) { + try table_layout.writeAccountTruncatedPadded(out, value, widths[index]); + } else { + try table_layout.writeTruncatedPadded(out, value, widths[index]); + } + } + try out.writeAll("\n"); +} + +fn minimumForWidths(widths: []const usize) usize { + var total: usize = 0; + for (widths) |width| total += width; + return total + (widths.len - 1) * separator_width; +} + +fn nextResetText(allocator: std.mem.Allocator, snapshot: ?registry.RateLimitSnapshot) ![]u8 { + var next: ?i64 = null; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + if (snapshot) |usage| { + for ([_]?registry.RateLimitWindow{ usage.primary, usage.secondary }) |maybe_window| { + const window = maybe_window orelse continue; + const timestamp = window.resets_at orelse continue; + if (timestamp <= now) continue; + if (next == null or timestamp < next.?) next = timestamp; + } + } + return rows.formatTimestampAlloc(allocator, next); +} + +fn creditExpiry(snapshot: ?registry.RateLimitSnapshot, index: usize) ?[]const u8 { + const usage = snapshot orelse return null; + const details = usage.reset_credit_details orelse return null; + if (index >= details.credits.len) return null; + const expiry = details.credits[index].expires_at orelse return null; + return if (expiry.len >= 10) expiry[0..10] else expiry; +} diff --git a/src/cli/live_view.zig b/src/cli/live_view.zig index fd3310c1..6069677b 100644 --- a/src/cli/live_view.zig +++ b/src/cli/live_view.zig @@ -21,7 +21,6 @@ const TuiSession = tui_mod.TuiSession; const mapTuiOutputError = tui_mod.mapTuiOutputError; const indexWidth = row_data.indexWidth; const renderSwitchScreenViewport = render.renderSwitchScreenViewport; -const renderListScreenViewport = render.renderListScreenViewport; const shouldUseNumberedSwitchSelector = picker.shouldUseNumberedSwitchSelector; const selectWithNumbers = picker.selectWithNumbers; const dupeOptionalAccountKey = picker.dupeOptionalAccountKey; @@ -258,6 +257,9 @@ pub fn viewAccountsWithLiveUpdates( var frame: std.Io.Writer.Allocating = .init(allocator); defer frame.deinit(); + var body: std.Io.Writer.Allocating = .init(allocator); + defer body.deinit(); + while (true) { if (try controller.maybe_take_updated_display(controller.context)) |updated| { current_display.deinit(allocator); @@ -274,12 +276,15 @@ pub fn viewAccountsWithLiveUpdates( } if (needs_render or now_second != last_render_second) { const rows = try rows_cache.ensure(allocator, current_display.borrowed()); - rendered_row_count = rows.items.len; + body.clearRetainingCapacity(); + var styled_body = style.StyledWriter.init(&body.writer, use_color); + try render.renderListBody(allocator, &styled_body, ¤t_display.reg, rows.items, @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), rows.widths, tui.terminalCols()); + rendered_row_count = std.mem.count(u8, body.written(), "\n") - 1; const status_line = try controller.build_status_line(controller.context, allocator, current_display.borrowed()); defer allocator.free(status_line); const viewport = live_tui.listViewport( tui.terminalRows(), - rows.items.len, + rendered_row_count, live_tui.listFixedLines(status_line), &viewport_start, ); @@ -288,12 +293,9 @@ pub fn viewAccountsWithLiveUpdates( frame.clearRetainingCapacity(); var styled_frame = style.StyledWriter.init(&frame.writer, use_color); - renderListScreenViewport( + render.renderListBodyViewport( &styled_frame, - ¤t_display.reg, - rows.items, - @max(@as(usize, 2), indexWidth(rows.selectable_row_indices.len)), - rows.widths, + body.written(), status_line, bounded_viewport, ) catch |err| return mapTuiOutputError(err); @@ -313,8 +315,6 @@ pub fn viewAccountsWithLiveUpdates( if (key_count != 0) { const max_rows = live_tui.maxTableRows(tui.terminalRows(), live_tui.listFixedLines("status")); const wheel_rows = live_tui.mouseWheelRows(max_rows); - const rows = try rows_cache.ensure(allocator, current_display.borrowed()); - rendered_row_count = rows.items.len; for (key_buf[0..key_count]) |key| { if (live_tui.applyListViewportKey(rendered_row_count, max_rows, &viewport_start, wheel_rows, key)) { diff --git a/src/cli/render.zig b/src/cli/render.zig index a455ab36..aec82b69 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -189,27 +189,7 @@ pub fn renderSwitchListViewport( const visible = visibleRowRange(rows.len, viewport); var displayed_counter = dataRowCount(rows[0..visible.start]); for (rows[visible.start..visible.end]) |row| { - if (row.is_header) { - try table.writeGroupRow(writer, row.account); - continue; - } - - const is_cursor = cursor != null and cursor.? == displayed_counter; - const is_active = row.is_active; - var prefix_buf: [64]u8 = undefined; - const prefix = liveTableIndexPrefix( - &prefix_buf, - activeRowMarker(is_cursor, is_active), - displayed_counter + 1, - idx_width, - ); - try table.writeDataRow( - writer, - prefix, - liveAccountCells(row), - switchRowStyle(row, is_cursor, is_active), - ); - displayed_counter += 1; + try writeSwitchRow(writer, &table, row, idx_width, cursor, &displayed_counter); } } @@ -400,3 +380,102 @@ fn writeIndexPadded(out: *std.Io.Writer, idx: usize, width: usize) !void { } try out.writeAll(idx_str); } + +fn writeSwitchRow( + writer: *style.StyledWriter, + table: *const table_layout.LiveTable, + row: SwitchRow, + idx_width: usize, + cursor: ?usize, + displayed_counter: *usize, +) !void { + if (row.is_header) { + try table.writeGroupRow(writer, row.account); + return; + } + + const is_cursor = cursor != null and cursor.? == displayed_counter.*; + const is_active = row.is_active; + var prefix_buf: [64]u8 = undefined; + const prefix = liveTableIndexPrefix( + &prefix_buf, + activeRowMarker(is_cursor, is_active), + displayed_counter.* + 1, + idx_width, + ); + try table.writeDataRow( + writer, + prefix, + liveAccountCells(row), + switchRowStyle(row, is_cursor, is_active), + ); + displayed_counter.* += 1; +} + +/// Render the compact live account table. +pub fn renderListBody( + allocator: std.mem.Allocator, + writer: *style.StyledWriter, + reg: *const registry.Registry, + rows: []const SwitchRow, + idx_width: usize, + widths: SwitchWidths, + max_cols: ?usize, +) !void { + _ = widths; + const details = @import("list_details.zig"); + const max_cards = blk: { + var count: usize = 0; + for (reg.accounts.items) |account| { + if (account.last_usage) |usage| { + if (usage.reset_credit_details) |credits| count = @max(count, credits.credits.len); + } + } + break :blk count; + }; + const number_width = idx_width + 1; + try details.writeHeader(allocator, writer.out, reg, number_width, max_cols); + var displayed_counter: usize = 0; + for (rows) |row| { + if (row.is_header) continue; + const idx = row.account_index orelse continue; + if (idx >= reg.accounts.items.len) continue; + displayed_counter += 1; + const account = reg.accounts.items[idx]; + var number_buf: [32]u8 = undefined; + const marker: []const u8 = if (row.has_error) "!" else if (row.is_active) "*" else " "; + const number = try std.fmt.bufPrint(&number_buf, "{s}{d}", .{ marker, displayed_counter }); + try details.write(allocator, writer.out, account, .{ + .number = number, + .account = row.account, + .plan = row.plan, + .rate_5h = row.rate_5h, + .rate_week = row.rate_week, + .last = row.last, + }, number_width, max_cards, max_cols); + } +} + +pub fn renderListBodyViewport( + writer: *style.StyledWriter, + body: []const u8, + status_line: []const u8, + viewport: LiveListViewport, +) !void { + var lines = std.mem.splitScalar(u8, body, '\n'); + if (lines.next()) |header| { + try writer.print("{s}\n", .{header}); + } + var index: usize = 0; + while (lines.next()) |line| : (index += 1) { + if (index < viewport.start_row) continue; + if (viewport.max_rows) |limit| { + if (index - viewport.start_row >= limit) break; + } + if (line.len != 0) try writer.print("{s}\n", .{line}); + } + if (status_line.len != 0) { + try writeLiveStatusLine(writer.out, status_line, writer.color_enabled, viewport.max_cols); + } + try writeListTuiFooterBounded(writer.out, writer.color_enabled, viewport.max_cols); +} diff --git a/src/cli/rows.zig b/src/cli/rows.zig index 0e1d2321..161d926b 100644 --- a/src/cli/rows.zig +++ b/src/cli/rows.zig @@ -354,3 +354,16 @@ pub fn indexWidth(count: usize) usize { } return width; } + +pub fn formatTimestampAlloc(allocator: std.mem.Allocator, timestamp: ?i64) ![]u8 { + var tm: c.struct_tm = undefined; + if (timestamp == null or !localtimeCompat(timestamp.?, &tm)) return allocator.dupe(u8, "unavailable"); + const year: u32 = @intCast(tm.tm_year + 1900); + const month: u32 = @intCast(tm.tm_mon + 1); + const day: u32 = @intCast(tm.tm_mday); + const hour: u32 = @intCast(tm.tm_hour); + const minute: u32 = @intCast(tm.tm_min); + return std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}", .{ + year, month, day, hour, minute, + }); +} diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 24db81f5..eda32a3c 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -150,7 +150,7 @@ fn writePadded(out: *std.Io.Writer, value: []const u8, width: usize) !void { try out.splatByteAll(' ', width - value.len); } -fn writeTruncatedPadded(out: *std.Io.Writer, value: []const u8, width: usize) !void { +pub fn writeTruncatedPadded(out: *std.Io.Writer, value: []const u8, width: usize) !void { if (width == 0) return; if (value.len <= width) { try writePadded(out, value, width); @@ -164,7 +164,7 @@ fn writeTruncatedPadded(out: *std.Io.Writer, value: []const u8, width: usize) !v try out.writeAll("."); } -fn writeAccountTruncatedPadded(out: *std.Io.Writer, value: []const u8, width: usize) !void { +pub fn writeAccountTruncatedPadded(out: *std.Io.Writer, value: []const u8, width: usize) !void { if (width == 0) return; if (value.len <= width) { try writePadded(out, value, width); diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index b8d5c07d..94659355 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -108,9 +108,7 @@ pub fn updateUsage(allocator: std.mem.Allocator, reg: *Registry, account_key: [] for (reg.accounts.items) |*rec| { if (std.mem.eql(u8, rec.account_key, account_key)) { if (rec.last_usage) |*u| { - if (u.credits) |*c| { - if (c.balance) |b| allocator.free(b); - } + freeRateLimitSnapshot(allocator, u); } rec.last_usage = snapshot; rec.last_usage_at = now; @@ -194,6 +192,10 @@ pub fn syncActiveAccountFromAuthWithImporter(allocator: std.mem.Allocator, codex changed = true; } reg.accounts.items[idx].auth_mode = info.auth_mode; + if (reg.accounts.items[idx].auth_expires_at != info.access_token_expires_at) { + changed = true; + } + reg.accounts.items[idx].auth_expires_at = info.access_token_expires_at; const dest = try accountAuthPath(allocator, codex_home, rec_account_key); defer allocator.free(dest); @@ -603,6 +605,7 @@ pub fn accountFromAuth( .account_name = null, .plan = info.plan, .auth_mode = info.auth_mode, + .auth_expires_at = info.access_token_expires_at, .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = null, .last_usage = null, @@ -640,6 +643,7 @@ pub fn accountFromApiKeyMe( .account_name = owned_account_name, .plan = null, .auth_mode = .apikey, + .auth_expires_at = null, .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = null, .last_usage = null, diff --git a/src/registry/common.zig b/src/registry/common.zig index 3a97f9d2..4871f4d0 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -8,7 +8,7 @@ const c_time = @cImport({ pub const PlanType = enum { free, go, plus, prolite, pro, business, enterprise, edu, unknown }; pub const AuthMode = enum { chatgpt, apikey }; -pub const current_schema_version: u32 = 4; +pub const current_schema_version: u32 = 5; pub const min_supported_schema_version: u32 = 2; pub const private_file_permissions: std.Io.File.Permissions = switch (builtin.os.tag) { .windows => .default_file, @@ -64,11 +64,28 @@ pub const CreditsSnapshot = struct { balance: ?[]u8, }; +pub const RateLimitResetCredit = struct { + id: []u8, + reset_type: []u8, + status: []u8, + granted_at: []u8, + expires_at: ?[]u8, + title: ?[]u8, + description: ?[]u8, +}; + +pub const RateLimitResetCredits = struct { + available_count: i64, + total_earned_count: ?i64, + credits: []RateLimitResetCredit, +}; + pub const RateLimitSnapshot = struct { primary: ?RateLimitWindow, secondary: ?RateLimitWindow, credits: ?CreditsSnapshot, reset_credits: ?i64 = null, + reset_credit_details: ?RateLimitResetCredits = null, plan_type: ?PlanType, }; @@ -99,6 +116,7 @@ pub const AccountRecord = struct { account_name: ?[]u8, plan: ?PlanType, auth_mode: ?AuthMode, + auth_expires_at: ?i64 = null, created_at: i64, last_used_at: ?i64, last_usage: ?RateLimitSnapshot, @@ -177,6 +195,20 @@ pub fn freeRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: *const Rate if (snapshot.credits) |*c| { if (c.balance) |b| allocator.free(b); } + if (snapshot.reset_credit_details) |details| { + for (details.credits) |credit| freeRateLimitResetCredit(allocator, credit); + allocator.free(details.credits); + } +} + +pub fn freeRateLimitResetCredit(allocator: std.mem.Allocator, credit: RateLimitResetCredit) void { + allocator.free(credit.id); + allocator.free(credit.reset_type); + allocator.free(credit.status); + allocator.free(credit.granted_at); + if (credit.expires_at) |value| allocator.free(value); + if (credit.title) |value| allocator.free(value); + if (credit.description) |value| allocator.free(value); } pub fn freeRolloutSignature(allocator: std.mem.Allocator, signature: *const RolloutSignature) void { @@ -213,15 +245,63 @@ pub fn cloneRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: RateLimitS if (credits.balance) |balance| allocator.free(balance); }; + var cloned_reset_credit_details: ?RateLimitResetCredits = null; + if (snapshot.reset_credit_details) |details| { + cloned_reset_credit_details = try cloneRateLimitResetCredits(allocator, details); + } + return .{ .primary = snapshot.primary, .secondary = snapshot.secondary, .credits = cloned_credits, .reset_credits = snapshot.reset_credits, + .reset_credit_details = cloned_reset_credit_details, .plan_type = snapshot.plan_type, }; } +pub fn cloneRateLimitResetCredits(allocator: std.mem.Allocator, details: RateLimitResetCredits) !RateLimitResetCredits { + var cloned_credits_list = try allocator.alloc(RateLimitResetCredit, details.credits.len); + errdefer allocator.free(cloned_credits_list); + var cloned_count: usize = 0; + errdefer for (cloned_credits_list[0..cloned_count]) |credit| freeRateLimitResetCredit(allocator, credit); + for (details.credits, 0..) |credit, idx| { + cloned_credits_list[idx] = try cloneRateLimitResetCredit(allocator, credit); + cloned_count += 1; + } + return .{ + .available_count = details.available_count, + .total_earned_count = details.total_earned_count, + .credits = cloned_credits_list, + }; +} + +fn cloneRateLimitResetCredit(allocator: std.mem.Allocator, credit: RateLimitResetCredit) !RateLimitResetCredit { + const id = try allocator.dupe(u8, credit.id); + errdefer allocator.free(id); + const reset_type = try allocator.dupe(u8, credit.reset_type); + errdefer allocator.free(reset_type); + const status = try allocator.dupe(u8, credit.status); + errdefer allocator.free(status); + const granted_at = try allocator.dupe(u8, credit.granted_at); + errdefer allocator.free(granted_at); + const expires_at = try cloneOptionalStringAlloc(allocator, credit.expires_at); + errdefer if (expires_at) |value| allocator.free(value); + const title = try cloneOptionalStringAlloc(allocator, credit.title); + errdefer if (title) |value| allocator.free(value); + const description = try cloneOptionalStringAlloc(allocator, credit.description); + errdefer if (description) |value| allocator.free(value); + return .{ + .id = id, + .reset_type = reset_type, + .status = status, + .granted_at = granted_at, + .expires_at = expires_at, + .title = title, + .description = description, + }; +} + pub fn setRolloutSignature( allocator: std.mem.Allocator, target: *?RolloutSignature, @@ -264,9 +344,27 @@ pub fn rateLimitSnapshotEqual(a: RateLimitSnapshot, b: RateLimitSnapshot) bool { rateLimitWindowEqual(a.secondary, b.secondary) and creditsEqual(a.credits, b.credits) and a.reset_credits == b.reset_credits and + resetCreditDetailsEqual(a.reset_credit_details, b.reset_credit_details) and a.plan_type == b.plan_type; } +pub fn resetCreditDetailsEqual(a: ?RateLimitResetCredits, b: ?RateLimitResetCredits) bool { + if (a == null and b == null) return true; + if (a == null or b == null) return false; + if (a.?.available_count != b.?.available_count or a.?.total_earned_count != b.?.total_earned_count) return false; + if (a.?.credits.len != b.?.credits.len) return false; + for (a.?.credits, b.?.credits) |left, right| { + if (!optionalStringEqual(left.id, right.id) or + !optionalStringEqual(left.reset_type, right.reset_type) or + !optionalStringEqual(left.status, right.status) or + !optionalStringEqual(left.granted_at, right.granted_at) or + !optionalStringEqual(left.expires_at, right.expires_at) or + !optionalStringEqual(left.title, right.title) or + !optionalStringEqual(left.description, right.description)) return false; + } + return true; +} + pub fn rateLimitWindowEqual(a: ?RateLimitWindow, b: ?RateLimitWindow) bool { if (a == null and b == null) return true; if (a == null or b == null) return false; diff --git a/src/registry/import.zig b/src/registry/import.zig index 6347ed33..1b556848 100644 --- a/src/registry/import.zig +++ b/src/registry/import.zig @@ -610,6 +610,7 @@ fn syncCurrentAuthBestEffort( } reg.accounts.items[idx].plan = info.plan; reg.accounts.items[idx].auth_mode = info.auth_mode; + reg.accounts.items[idx].auth_expires_at = info.access_token_expires_at; } else { var record = try accountFromAuth(allocator, "", &info); errdefer freeAccountRecord(allocator, &record); diff --git a/src/registry/parse.zig b/src/registry/parse.zig index f3a66557..2fcd4d68 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -8,6 +8,8 @@ const RateLimitSnapshot = common.RateLimitSnapshot; const RateLimitWindow = common.RateLimitWindow; const RolloutSignature = common.RolloutSignature; const CreditsSnapshot = common.CreditsSnapshot; +const RateLimitResetCredit = common.RateLimitResetCredit; +const RateLimitResetCredits = common.RateLimitResetCredits; pub fn normalizePlanType(s: []const u8) PlanType { if (std.ascii.eqlIgnoreCase(s, "free")) return .free; @@ -50,7 +52,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_versio .object => |o| o, else => return null, }; - var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .plan_type = null }; + var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .reset_credit_details = null, .plan_type = null }; if (obj.get("plan_type")) |p| { switch (p) { @@ -62,9 +64,104 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_versio if (obj.get("secondary")) |p| snap.secondary = parseWindow(p); if (obj.get("credits")) |c| snap.credits = parseCredits(allocator, c); snap.reset_credits = readInt(obj.get("reset_credits")); + if (obj.get("reset_credit_details")) |details| { + snap.reset_credit_details = parseResetCreditDetails(allocator, details) catch null; + } return snap; } +pub fn parseResetCreditDetails(allocator: std.mem.Allocator, v: std.json.Value) !?RateLimitResetCredits { + const obj = switch (v) { + .object => |o| o, + else => return null, + }; + const available_count = readInt(obj.get("available_count")) orelse return null; + const total_earned_count = readInt(obj.get("total_earned_count")); + var credits = std.ArrayList(RateLimitResetCredit).empty; + errdefer { + for (credits.items) |credit| common.freeRateLimitResetCredit(allocator, credit); + credits.deinit(allocator); + } + if (obj.get("credits")) |credits_value| { + const items = switch (credits_value) { + .array => |array| array.items, + else => return null, + }; + for (items) |item| { + const item_obj = switch (item) { + .object => |item_obj| item_obj, + else => continue, + }; + const credit = try parseResetCredit(allocator, item_obj) orelse continue; + credits.append(allocator, credit) catch |err| { + common.freeRateLimitResetCredit(allocator, credit); + return err; + }; + } + } + return .{ + .available_count = available_count, + .total_earned_count = total_earned_count, + .credits = try credits.toOwnedSlice(allocator), + }; +} + +fn parseResetCredit(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !?RateLimitResetCredit { + const id = try requiredStringAlloc(allocator, obj.get("id")) orelse return null; + errdefer allocator.free(id); + const reset_type = try requiredStringAlloc(allocator, obj.get("reset_type")) orelse { + allocator.free(id); + return null; + }; + errdefer allocator.free(reset_type); + const status = try requiredStringAlloc(allocator, obj.get("status")) orelse { + allocator.free(id); + allocator.free(reset_type); + return null; + }; + errdefer allocator.free(status); + const granted_at = try requiredStringAlloc(allocator, obj.get("granted_at")) orelse { + allocator.free(id); + allocator.free(reset_type); + allocator.free(status); + return null; + }; + errdefer allocator.free(granted_at); + const expires_at = try parseOptionalStoredStringAlloc(allocator, obj.get("expires_at")); + errdefer if (expires_at) |value| allocator.free(value); + const title = try parseOptionalStoredStringAlloc(allocator, obj.get("title")); + errdefer if (title) |value| allocator.free(value); + const description = try parseOptionalStoredStringAlloc(allocator, obj.get("description")); + errdefer if (description) |value| allocator.free(value); + return .{ + .id = id, + .reset_type = reset_type, + .status = status, + .granted_at = granted_at, + .expires_at = expires_at, + .title = title, + .description = description, + }; +} + +fn requiredStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]u8 { + const text = switch (value orelse return null) { + .string => |s| s, + else => return null, + }; + return try allocator.dupe(u8, text); +} + +fn parseOptionalStoredStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]u8 { + const text = switch (value orelse return null) { + .string => |s| s, + .null => return null, + else => return null, + }; + if (text.len == 0) return null; + return try allocator.dupe(u8, text); +} + pub fn parseLiveConfig(cfg: *LiveConfig, v: std.json.Value) void { const obj = switch (v) { .object => |o| o, @@ -155,6 +252,7 @@ pub fn readInt(v: ?std.json.Value) ?i64 { if (v == null) return null; switch (v.?) { .integer => |i| return i, + .number_string, .string => |s| return std.fmt.parseInt(i64, s, 10) catch null, else => return null, } } diff --git a/src/registry/root.zig b/src/registry/root.zig index 1098d3ec..a2225bf9 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -23,6 +23,8 @@ pub const realPathAlloc = common.realPathAlloc; pub const readFileAlloc = common.readFileAlloc; pub const RateLimitWindow = common.RateLimitWindow; pub const CreditsSnapshot = common.CreditsSnapshot; +pub const RateLimitResetCredit = common.RateLimitResetCredit; +pub const RateLimitResetCredits = common.RateLimitResetCredits; pub const RateLimitSnapshot = common.RateLimitSnapshot; pub const RolloutSignature = common.RolloutSignature; pub const ApiConfig = common.ApiConfig; @@ -41,14 +43,17 @@ pub const defaultApiConfig = common.defaultApiConfig; pub const defaultLiveConfig = common.defaultLiveConfig; pub const freeAccountRecord = common.freeAccountRecord; pub const freeRateLimitSnapshot = common.freeRateLimitSnapshot; +pub const freeRateLimitResetCredit = common.freeRateLimitResetCredit; pub const freeRolloutSignature = common.freeRolloutSignature; pub const rolloutSignaturesEqual = common.rolloutSignaturesEqual; pub const cloneRolloutSignature = common.cloneRolloutSignature; pub const cloneRateLimitSnapshot = common.cloneRateLimitSnapshot; +pub const cloneRateLimitResetCredits = common.cloneRateLimitResetCredits; pub const setRolloutSignature = common.setRolloutSignature; pub const setAccountLastLocalRollout = common.setAccountLastLocalRollout; pub const rateLimitSnapshotsEqual = common.rateLimitSnapshotsEqual; pub const rateLimitSnapshotEqual = common.rateLimitSnapshotEqual; +pub const resetCreditDetailsEqual = common.resetCreditDetailsEqual; pub const rateLimitWindowEqual = common.rateLimitWindowEqual; pub const creditsEqual = common.creditsEqual; pub const optionalStringEqual = common.optionalStringEqual; diff --git a/src/registry/storage.zig b/src/registry/storage.zig index 39d006ce..e3f14b78 100644 --- a/src/registry/storage.zig +++ b/src/registry/storage.zig @@ -209,6 +209,7 @@ fn migrateLegacyRecord( .account_name = null, .plan = info.plan orelse legacy.plan, .auth_mode = info.auth_mode, + .auth_expires_at = info.access_token_expires_at, .created_at = legacy.created_at, .last_used_at = legacy.last_used_at, .last_usage = legacy.last_usage, @@ -453,7 +454,7 @@ pub fn loadRegistry(allocator: std.mem.Allocator, codex_home: []const u8) !Regis (schema_version == current_schema_version and currentLayoutNeedsRewrite(root_obj)); var reg = switch (schema_version) { 2 => try loadLegacyRegistryV2(allocator, codex_home, root_obj), - 3, 4 => try loadCurrentRegistry(allocator, root_obj, schema_version), + 3, 4, 5 => try loadCurrentRegistry(allocator, root_obj, schema_version), else => { std.log.err( "registry schema_version {d} is older than the minimum supported {d}; use an intermediate codex-auth release or import --purge", diff --git a/src/registry/storage_parse.zig b/src/registry/storage_parse.zig index 9bcfe14b..3f2489d5 100644 --- a/src/registry/storage_parse.zig +++ b/src/registry/storage_parse.zig @@ -43,6 +43,7 @@ pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap, .account_name = try parseOptionalStoredStringAlloc(allocator, obj.get("account_name")), .plan = null, .auth_mode = null, + .auth_expires_at = readInt(obj.get("auth_expires_at")), .created_at = readInt(obj.get("created_at")) orelse std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = readInt(obj.get("last_used_at")), .last_usage = null, diff --git a/src/workflows/live_display.zig b/src/workflows/live_display.zig index d9dfefad..093487b6 100644 --- a/src/workflows/live_display.zig +++ b/src/workflows/live_display.zig @@ -36,6 +36,8 @@ pub fn switchLiveUsageFieldsEqual( maybe_a: ?*const registry.AccountRecord, maybe_b: ?*const registry.AccountRecord, ) bool { + if ((if (maybe_a) |rec| rec.auth_expires_at else null) != + (if (maybe_b) |rec| rec.auth_expires_at else null)) return false; const a_usage = if (maybe_a) |rec| rec.last_usage else null; const b_usage = if (maybe_b) |rec| rec.last_usage else null; if (!registry.rateLimitSnapshotsEqual(a_usage, b_usage)) return false; @@ -82,6 +84,7 @@ pub fn applySwitchLiveUsageDeltaToLatest( const latest_rec = &latest.accounts.items[latest_idx]; if (!switchLiveUsageFieldsEqual(base_rec, latest_rec)) return false; + latest_rec.auth_expires_at = refreshed_rec.auth_expires_at; if (refreshed_rec.last_usage) |snapshot| { const cloned_snapshot = try registry.cloneRateLimitSnapshot(allocator, snapshot); registry.updateUsage(allocator, latest, refreshed_rec.account_key, cloned_snapshot); @@ -211,6 +214,7 @@ pub fn cloneAccountRecord(allocator: std.mem.Allocator, rec: *const registry.Acc .account_name = account_name, .plan = rec.plan, .auth_mode = rec.auth_mode, + .auth_expires_at = rec.auth_expires_at, .created_at = rec.created_at, .last_used_at = rec.last_used_at, .last_usage = last_usage, diff --git a/src/workflows/results.zig b/src/workflows/results.zig index ed767cd4..85b0c19e 100644 --- a/src/workflows/results.zig +++ b/src/workflows/results.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const app_runtime = @import("../core/runtime.zig"); const display_rows = @import("../tui/display.zig"); const registry = @import("../registry/root.zig"); const usage_refresh = @import("usage.zig"); @@ -55,10 +56,16 @@ pub const UsageView = struct { secondary: ?registry.RateLimitWindow = null, credits: ?CreditsView = null, reset_credits: ?i64 = null, + reset_credit_details: ?registry.RateLimitResetCredits = null, + next_reset_at: ?i64 = null, refresh: UsageRefreshView, pub fn deinit(self: *UsageView, allocator: std.mem.Allocator) void { if (self.credits) |*credits| credits.deinit(allocator); + if (self.reset_credit_details) |details| { + for (details.credits) |credit| registry.freeRateLimitResetCredit(allocator, credit); + allocator.free(details.credits); + } self.refresh.deinit(allocator); self.* = undefined; } @@ -72,6 +79,7 @@ pub const AccountView = struct { account_name: ?[]u8, plan: ?registry.PlanType, auth_mode: ?registry.AuthMode, + auth_expires_at: ?i64, active: bool, created_at: i64, last_used_at: ?i64, @@ -277,6 +285,7 @@ fn buildAccountView( .account_name = account_name, .plan = registry.resolveDisplayPlan(rec), .auth_mode = rec.auth_mode, + .auth_expires_at = rec.auth_expires_at, .active = isActive(reg, account_idx), .created_at = rec.created_at, .last_used_at = rec.last_used_at, @@ -296,7 +305,7 @@ fn buildUsageView( .source = .none, .refresh = refresh, }; - const credits = try cloneCreditsView(allocator, snapshot.credits); + var credits = try cloneCreditsView(allocator, snapshot.credits); errdefer if (credits) |*value| value.deinit(allocator); return .{ @@ -306,10 +315,32 @@ fn buildUsageView( .secondary = snapshot.secondary, .credits = credits, .reset_credits = snapshot.reset_credits, + .reset_credit_details = try cloneResetCreditDetailsView(allocator, snapshot.reset_credit_details), + .next_reset_at = nextResetAt(snapshot), .refresh = refresh, }; } +fn cloneResetCreditDetailsView( + allocator: std.mem.Allocator, + details: ?registry.RateLimitResetCredits, +) !?registry.RateLimitResetCredits { + const value = details orelse return null; + return try registry.cloneRateLimitResetCredits(allocator, value); +} + +fn nextResetAt(snapshot: registry.RateLimitSnapshot) ?i64 { + var next: ?i64 = null; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + for ([_]?registry.RateLimitWindow{ snapshot.primary, snapshot.secondary }) |window_opt| { + const window = window_opt orelse continue; + const reset_at = window.resets_at orelse continue; + if (reset_at <= now) continue; + if (next == null or reset_at < next.?) next = reset_at; + } + return next; +} + fn cloneCreditsView(allocator: std.mem.Allocator, credits: ?registry.CreditsSnapshot) !?CreditsView { const value = credits orelse return null; diff --git a/src/workflows/usage.zig b/src/workflows/usage.zig index 5caf8208..9a28226a 100644 --- a/src/workflows/usage.zig +++ b/src/workflows/usage.zig @@ -25,6 +25,7 @@ const ForegroundUsageWorkerResult = struct { missing_auth: bool = false, error_name: ?[]const u8 = null, snapshot: ?registry.RateLimitSnapshot = null, + auth_expires_at: ?i64 = null, pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { if (self.snapshot) |*snapshot| { @@ -409,6 +410,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable .missing_auth = batch_result.missing_auth, .error_name = batch_result.error_name, .snapshot = batch_result.snapshot, + .auth_expires_at = batch_result.auth_expires_at, }; batch_result.snapshot = null; } @@ -448,6 +450,12 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable } const outcome = &state.outcomes[idx]; if (!worker_result.requested) continue; + if (worker_result.auth_expires_at) |expiry| { + if (reg.accounts.items[idx].auth_expires_at != expiry) { + reg.accounts.items[idx].auth_expires_at = expiry; + registry_changed = true; + } + } outcome.* = .{ .attempted = true, .method = .api, @@ -665,6 +673,7 @@ fn foregroundUsageRefreshWorker( .status_code = fetch_result.status_code, .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, + .auth_expires_at = fetch_result.auth_expires_at, }; if (fetch_result.snapshot) |snapshot| { @@ -674,6 +683,7 @@ fn foregroundUsageRefreshWorker( .status_code = fetch_result.status_code, .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, + .auth_expires_at = fetch_result.auth_expires_at, .error_name = @errorName(err), }; return; diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index a79329bc..5c6fc87c 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -67,6 +67,49 @@ test "parse usage api response maps live usage windows and plan" { try std.testing.expectEqual(@as(?i64, 3), snapshot.reset_credits); } +test "parse reset credit details keeps every returned field" { + const gpa = std.testing.allocator; + const body = + \\{ + \\ "available_count": 2, + \\ "total_earned_count": 4, + \\ "credits": [ + \\ { + \\ "id": "credit-1", + \\ "reset_type": "codex_rate_limits", + \\ "status": "available", + \\ "granted_at": "2026-06-17T00:00:00Z", + \\ "expires_at": "2026-07-17T00:00:00Z", + \\ "title": "Full reset", + \\ "description": "Ready to redeem" + \\ }, + \\ { + \\ "id": "credit-2", + \\ "reset_type": "future_reset_type", + \\ "status": "future_status", + \\ "granted_at": "2026-06-18T00:00:00Z", + \\ "expires_at": null, + \\ "title": null, + \\ "description": null + \\ } + \\ ] + \\} + ; + + const details = (try usage_api.parseResetCreditResponse(gpa, body)) orelse return error.TestExpectedEqual; + defer { + for (details.credits) |credit| registry.freeRateLimitResetCredit(gpa, credit); + gpa.free(details.credits); + } + + try std.testing.expectEqual(@as(i64, 2), details.available_count); + try std.testing.expectEqual(@as(?i64, 4), details.total_earned_count); + try std.testing.expectEqual(@as(usize, 2), details.credits.len); + try std.testing.expectEqualStrings("credit-1", details.credits[0].id); + try std.testing.expectEqualStrings("2026-07-17T00:00:00Z", details.credits[0].expires_at.?); + try std.testing.expect(details.credits[1].expires_at == null); +} + test "parse usage api response keeps numeric credits without windows" { const gpa = std.testing.allocator; const body = diff --git a/tests/auth_test.zig b/tests/auth_test.zig index 6f149d2c..44e06437 100644 --- a/tests/auth_test.zig +++ b/tests/auth_test.zig @@ -24,13 +24,19 @@ test "parse auth info from jwt" { const p64 = try b64url(gpa, payload); defer gpa.free(p64); + const access_payload = "{\"exp\":1773491460}"; + const access_p64 = try b64url(gpa, access_payload); + defer gpa.free(access_p64); + const access_jwt = try std.mem.concat(gpa, u8, &[_][]const u8{ h64, ".", access_p64, ".sig" }); + defer gpa.free(access_jwt); + const jwt = try std.mem.concat(gpa, u8, &[_][]const u8{ h64, ".", p64, ".sig" }); defer gpa.free(jwt); const json = try std.fmt.allocPrint( gpa, - "{{\"tokens\":{{\"access_token\":\"access-user@example.com\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", - .{ chatgpt_account_id, jwt }, + "{{\"tokens\":{{\"access_token\":\"{s}\",\"account_id\":\"{s}\",\"id_token\":\"{s}\"}}}}", + .{ access_jwt, chatgpt_account_id, jwt }, ); defer gpa.free(json); @@ -53,7 +59,8 @@ test "parse auth info from jwt" { defer gpa.free(expected_record_key); try std.testing.expect(std.mem.eql(u8, info.record_key.?, expected_record_key)); try std.testing.expect(info.access_token != null); - try std.testing.expect(std.mem.eql(u8, info.access_token.?, "access-user@example.com")); + try std.testing.expect(std.mem.eql(u8, info.access_token.?, access_jwt)); + try std.testing.expectEqual(@as(?i64, 1773491460), info.access_token_expires_at); } test "parse auth info uses default organization when account id is missing" { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 9e0931fb..80344337 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1586,3 +1586,95 @@ test "Scenario: Given a usage snapshot plan when building switch rows then the d try std.testing.expectEqualStrings("Business", rows.items[0].plan); } + +test "live list details show reset-card expiry columns and scroll within the viewport" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + const details = try codex_auth.api.usage.parseResetCreditResponse(gpa, + \\{"available_count":1,"total_earned_count":2,"credits":[ + \\{"id":"first-card","reset_type":"codex_rate_limits","status":"used","granted_at":"2026-08-01T00:00:00Z","expires_at":null,"title":"First card"}, + \\{"id":"last-card","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-09-01T00:00:00Z","expires_at":"2026-10-01T00:00:00Z","title":"Last card","description":"Reset quota.\u001b[2J"}]} + ); + reg.accounts.items[0].auth_expires_at = 1790000000; + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 30, .window_minutes = 300, .resets_at = 1790000100 }, + .secondary = null, + .credits = null, + .plan_type = .pro, + .reset_credit_details = details, + }; + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var styled_body = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, 100); + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "NEXT RESET", "2026-10-01" }) |expected| { + try std.testing.expect(std.mem.indexOf(u8, body.written(), expected) != null); + } + try std.testing.expect(std.mem.indexOfScalar(u8, body.written(), 27) == null); + var frame: std.Io.Writer.Allocating = .init(gpa); + defer frame.deinit(); + var styled_frame = StyledWriter.init(&frame.writer, false); + const count = std.mem.count(u8, body.written(), "\n") - 1; + var start: usize = count; + var viewport = live_tui.listViewport(7, count, live_tui.listFixedLines("ready"), &start); + viewport.max_cols = 100; + try cli.render.renderListBodyViewport(&styled_frame, body.written(), "ready", viewport); + try std.testing.expect(std.mem.indexOf(u8, frame.written(), "2026-10-01") != null); + try std.testing.expect(std.mem.indexOf(u8, frame.written(), "ACCOUNT") != null); + try std.testing.expect(std.mem.indexOf(u8, frame.written(), "ready") != null); + try std.testing.expect(std.mem.count(u8, frame.written(), "\n") <= 7); + + body.clearRetainingCapacity(); + try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, 40); + var lines = std.mem.splitScalar(u8, body.written(), '\n'); + while (lines.next()) |line| try std.testing.expect(line.len <= 40); +} + +test "live list keeps the active marker visible for double-digit accounts" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + for (1..11) |idx| try appendNumberedTestAccount(gpa, ®, idx); + reg.active_account_key = try gpa.dupe(u8, "user-010::acc-010"); + + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var writer = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &writer, ®, rows.items, 2, rows.widths, 120); + + try std.testing.expect(std.mem.indexOf(u8, body.written(), "*10") != null); +} + +test "live list distinguishes unavailable reset details from an empty server list" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var writer = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &writer, ®, rows.items, 2, rows.widths, 120); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "#*") != null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "CREDIT 1 EXP") == null); + reg.accounts.items[0].last_usage = .{ + .primary = null, + .secondary = null, + .credits = null, + .plan_type = .pro, + .reset_credit_details = try codex_auth.api.usage.parseResetCreditResponse(gpa, + \\{"available_count":0,"credits":[]} + ), + }; + body.clearRetainingCapacity(); + try cli.render.renderListBody(gpa, &writer, ®, rows.items, 2, rows.widths, 120); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "#*") != null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "CREDIT 1 EXP") == null); +} diff --git a/tests/registry_test.zig b/tests/registry_test.zig index a2dcdef2..afcad8d9 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -516,7 +516,7 @@ test "registry load normalizes schema four without previous active account key" defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"previous_active_account_key\": null") != null); } @@ -583,7 +583,7 @@ test "schema three registry plan values migrate to canonical product plans" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"team\"") == null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"business\"") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"enterprise\"") != null); @@ -825,7 +825,7 @@ test "legacy schema registry with legacy rollout attribution rewrites to normali defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"active_account_activated_at_ms\": 0") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"last_attributed_rollout\"") == null); } @@ -857,7 +857,7 @@ test "legacy current-layout registry version field rewrites to schema_version" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"interval_seconds\": 60") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"live\"") == null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"version\"") == null); @@ -946,7 +946,7 @@ test "v2 registry migrates active email records to current schema" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); const active_expect = try std.fmt.allocPrint(gpa, "\"active_account_key\": \"{s}\"", .{expected_account_id}); defer gpa.free(active_expect); try std.testing.expect(std.mem.indexOf(u8, contents, active_expect) != null); diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index c5f38383..d3ea9c72 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -268,6 +268,7 @@ test "live refresh merge preserves accounts newly added to the latest registry" defer refreshed.deinit(gpa); try appendLiveMergeTestAccount(gpa, &refreshed, "user-alpha::acct-alpha", "alpha@example.com", "alpha"); refreshed.accounts.items[0].account_name = try gpa.dupe(u8, "Alpha Workspace"); + refreshed.accounts.items[0].auth_expires_at = 1790000000; var latest: registry.Registry = .{ .schema_version = registry.current_schema_version, @@ -287,6 +288,7 @@ test "live refresh merge preserves accounts newly added to the latest registry" const alpha_idx = findAccountIndexByAccountKeyConst(&latest, "user-alpha::acct-alpha") orelse return error.TestExpectedEqual; const beta_idx = findAccountIndexByAccountKeyConst(&latest, "user-beta::acct-beta") orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("Alpha Workspace", latest.accounts.items[alpha_idx].account_name.?); + try std.testing.expectEqual(@as(?i64, 1790000000), latest.accounts.items[alpha_idx].auth_expires_at); try std.testing.expect(latest.accounts.items[beta_idx].account_name == null); const usage_overrides = try gpa.alloc(?[]const u8, refreshed.accounts.items.len); From 94ee2dc3b477e30e069e0011ca1298faf0ca3b78 Mon Sep 17 00:00:00 2001 From: zfaustk <4340287+zfaustk@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:46:36 +0800 Subject: [PATCH 2/7] feat: clarify list timestamps and table styling --- docs/commands/list.md | 6 ++-- src/cli/list_details.zig | 75 +++++++++++++++++++++++++++++---------- src/cli/render.zig | 4 +-- tests/cli_picker_test.zig | 13 +++++-- 4 files changed, 72 insertions(+), 26 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 8c171d93..1d414627 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -28,8 +28,10 @@ codex-auth list --json - `--live` keeps refreshing the terminal view and requires a TTY. - In live tables, `*` before the number marks the active account and `!` marks a refresh error. - Live account details show the earliest future quota reset, access-token expiry, - and the expiry date for each reset-credit card returned by the server. The live - table uses compact local timestamps and card dates; the JSON output retains the + and the expiry timestamp for each reset-credit card returned by the server. The + `NEXT RESET LOCAL` and `ACCESS EXP LOCAL` columns use local time. Credit expiry + values include their timezone (`UTC` for the server's `Z` timestamps, or the + returned numeric offset). The JSON output retains the complete card records, including status, grant date, ID, type, and description. - Access-token expiry is shown in local time and is not the account or subscription expiry; refreshing credentials can extend it. Plan expiry is shown as unavailable diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 738601ac..fcaf1bbc 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -2,17 +2,18 @@ const std = @import("std"); const registry = @import("../registry/root.zig"); const app_runtime = @import("../core/runtime.zig"); const rows = @import("rows.zig"); +const style = @import("style.zig"); const table_layout = @import("table_layout.zig"); const max_columns = 64; -const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "NEXT RESET", "LAST", "ACCESS TOKEN EXP" }; +const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "NEXT RESET LOCAL", "LAST", "ACCESS EXP LOCAL" }; const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 16, 10, 16 }; const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 10, 5, 10 }; const base_column_count = base_headers.len; -const credit_width: usize = 10; +const credit_width: usize = 23; const separator_width: usize = 3; -pub fn writeHeader(allocator: std.mem.Allocator, out: *std.Io.Writer, reg: *const registry.Registry, number_width: usize, max_cols: ?usize) !void { +pub fn writeHeader(writer: *style.StyledWriter, reg: *const registry.Registry, number_width: usize, max_cols: ?usize) !void { const visible_base = visibleBaseColumns(max_cols, number_width); const card_count = visibleCardCount(maxCardCount(reg), max_cols, visible_base, number_width); var values: [max_columns][]const u8 = undefined; @@ -34,13 +35,12 @@ pub fn writeHeader(allocator: std.mem.Allocator, out: *std.Io.Writer, reg: *cons minimums[count] = credit_width; count += 1; } - try writeAlignedRow(out, values[0..count], widths[0..count], minimums[0..count], max_cols, false); - _ = allocator; + try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, false, style.role.status, true); } pub fn write( allocator: std.mem.Allocator, - out: *std.Io.Writer, + writer: *style.StyledWriter, account: registry.AccountRecord, row: anytype, number_width: usize, @@ -58,6 +58,9 @@ pub fn write( var values: [max_columns][]const u8 = undefined; var widths: [max_columns]usize = undefined; var minimums: [max_columns]usize = undefined; + var owned_expiries: [max_columns][]u8 = undefined; + var owned_expiry_count: usize = 0; + defer for (owned_expiries[0..owned_expiry_count]) |expiry| allocator.free(expiry); var count: usize = 0; const base_values = [_][]const u8{ number, row.account, row.plan, row.rate_5h, row.rate_week, next_reset, row.last, token_expiry }; for (base_values, 0..) |value, index| { @@ -68,12 +71,16 @@ pub fn write( count += 1; } for (0..card_count) |index| { - values[count] = creditExpiry(account.last_usage, index) orelse "-"; + const expiry = try creditExpiryAlloc(allocator, account.last_usage, index); + owned_expiries[owned_expiry_count] = expiry; + owned_expiry_count += 1; + values[count] = expiry; widths[count] = credit_width; minimums[count] = credit_width; count += 1; } - try writeAlignedRow(out, values[0..count], widths[0..count], minimums[0..count], max_cols, true); + const row_style = if (number.len != 0 and number[0] == '!') style.role.error_text else if (number.len != 0 and number[0] == '*') style.role.success else ""; + try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, true, row_style, false); } fn maxCardCount(reg: *const registry.Registry) usize { @@ -126,12 +133,14 @@ fn minimumTableWidth(visible_base: [base_column_count]bool, card_count: usize, n } fn writeAlignedRow( - out: *std.Io.Writer, + writer: *style.StyledWriter, values: []const []const u8, desired: []const usize, minimums: []const usize, max_cols: ?usize, account_column: bool, + row_style: []const u8, + header: bool, ) !void { var widths: [max_columns]usize = undefined; for (desired, 0..) |width, index| widths[index] = width; @@ -163,15 +172,22 @@ fn writeAlignedRow( if (total > limit) break; } } + try writer.writeStyle(row_style); for (values, 0..) |value, index| { - if (index != 0) try out.writeAll(" | "); + if (index != 0) { + if (!header and row_style.len == 0) try writer.writeStyle(style.role.secondary); + try writer.writeAll(" | "); + if (!header and row_style.len == 0) try writer.reset(); + try writer.writeStyle(row_style); + } if (account_column and index == 1) { - try table_layout.writeAccountTruncatedPadded(out, value, widths[index]); + try table_layout.writeAccountTruncatedPadded(writer.out, value, widths[index]); } else { - try table_layout.writeTruncatedPadded(out, value, widths[index]); + try table_layout.writeTruncatedPadded(writer.out, value, widths[index]); } } - try out.writeAll("\n"); + try writer.reset(); + try writer.writeAll("\n"); } fn minimumForWidths(widths: []const usize) usize { @@ -194,10 +210,31 @@ fn nextResetText(allocator: std.mem.Allocator, snapshot: ?registry.RateLimitSnap return rows.formatTimestampAlloc(allocator, next); } -fn creditExpiry(snapshot: ?registry.RateLimitSnapshot, index: usize) ?[]const u8 { - const usage = snapshot orelse return null; - const details = usage.reset_credit_details orelse return null; - if (index >= details.credits.len) return null; - const expiry = details.credits[index].expires_at orelse return null; - return if (expiry.len >= 10) expiry[0..10] else expiry; +fn creditExpiryAlloc(allocator: std.mem.Allocator, snapshot: ?registry.RateLimitSnapshot, index: usize) ![]u8 { + const usage = snapshot orelse return allocator.dupe(u8, "-"); + const details = usage.reset_credit_details orelse return allocator.dupe(u8, "-"); + if (index >= details.credits.len) return allocator.dupe(u8, "-"); + const expiry = details.credits[index].expires_at orelse return allocator.dupe(u8, "-"); + if (expiry.len < 19 or expiry[10] != 'T') return std.fmt.allocPrint(allocator, "{s} TZ?", .{expiry}); + + var date_time: [19]u8 = undefined; + @memcpy(&date_time, expiry[0..19]); + date_time[10] = ' '; + + var zone: []const u8 = "TZ?"; + if (expiry.len > 19) { + if (expiry[expiry.len - 1] == 'Z' or expiry[expiry.len - 1] == 'z') { + zone = "UTC"; + } else { + var offset_start: ?usize = null; + for (expiry[19..], 19..) |value, offset| { + if (value == '+' or value == '-') { + offset_start = offset; + break; + } + } + if (offset_start) |start| zone = expiry[start..]; + } + } + return std.fmt.allocPrint(allocator, "{s} {s}", .{ date_time[0..], zone }); } diff --git a/src/cli/render.zig b/src/cli/render.zig index aec82b69..49f1283d 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -434,7 +434,7 @@ pub fn renderListBody( break :blk count; }; const number_width = idx_width + 1; - try details.writeHeader(allocator, writer.out, reg, number_width, max_cols); + try details.writeHeader(writer, reg, number_width, max_cols); var displayed_counter: usize = 0; for (rows) |row| { if (row.is_header) continue; @@ -445,7 +445,7 @@ pub fn renderListBody( var number_buf: [32]u8 = undefined; const marker: []const u8 = if (row.has_error) "!" else if (row.is_active) "*" else " "; const number = try std.fmt.bufPrint(&number_buf, "{s}{d}", .{ marker, displayed_counter }); - try details.write(allocator, writer.out, account, .{ + try details.write(allocator, writer, account, .{ .number = number, .account = row.account, .plan = row.plan, diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 80344337..ac1a3b71 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1610,8 +1610,8 @@ test "live list details show reset-card expiry columns and scroll within the vie var body: std.Io.Writer.Allocating = .init(gpa); defer body.deinit(); var styled_body = StyledWriter.init(&body.writer, false); - try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, 100); - for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "NEXT RESET", "2026-10-01" }) |expected| { + try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, null); + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "NEXT RESET LOCAL", "2026-10-01 00:00:00 UTC" }) |expected| { try std.testing.expect(std.mem.indexOf(u8, body.written(), expected) != null); } try std.testing.expect(std.mem.indexOfScalar(u8, body.written(), 27) == null); @@ -1621,7 +1621,7 @@ test "live list details show reset-card expiry columns and scroll within the vie const count = std.mem.count(u8, body.written(), "\n") - 1; var start: usize = count; var viewport = live_tui.listViewport(7, count, live_tui.listFixedLines("ready"), &start); - viewport.max_cols = 100; + viewport.max_cols = null; try cli.render.renderListBodyViewport(&styled_frame, body.written(), "ready", viewport); try std.testing.expect(std.mem.indexOf(u8, frame.written(), "2026-10-01") != null); try std.testing.expect(std.mem.indexOf(u8, frame.written(), "ACCOUNT") != null); @@ -1632,6 +1632,13 @@ test "live list details show reset-card expiry columns and scroll within the vie try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, 40); var lines = std.mem.splitScalar(u8, body.written(), '\n'); while (lines.next()) |line| try std.testing.expect(line.len <= 40); + + var color_body: std.Io.Writer.Allocating = .init(gpa); + defer color_body.deinit(); + var colored_writer = StyledWriter.init(&color_body.writer, true); + try cli.render.renderListBody(gpa, &colored_writer, ®, rows.items, 2, rows.widths, 120); + try std.testing.expect(std.mem.indexOf(u8, color_body.written(), ansi.cyan) != null); + try std.testing.expect(std.mem.indexOf(u8, color_body.written(), "\x1b[2m") != null); } test "live list keeps the active marker visible for double-digit accounts" { From 9169fba5f0d02a29e669ec0dec616f31b740948d Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 13:54:22 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20apply=20maintainer=20review=20?= =?UTF-8?q?=E2=80=94=20slim=20reset=20credits,=20local=20times,=20schema?= =?UTF-8?q?=20stays=20v4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove the NEXT RESET column and the next_reset_at field (the value is always one of the two timestamps already shown in the 5H/WEEKLY cells) - rename ACCESS EXP LOCAL -> ACCESS EXPIRE and drop the LOCAL suffix pattern; credit expiry renders in local time (UTC mixing removed) - remove the per-credit columns (C1/C2 EXP) and their width machinery - RESET CREDITS cell shows 'N (exp MMM-DD)': count plus the earliest available-card expiry in local form - slim persisted credit records to {id, status, expires_at, title}; wrapper available_count/total_earned_count and per-card reset_type/ granted_at/description dropped (upstream drops total_earned_count too) - rename auth_expires_at -> access_token_expires_at across the persistence/fetch/JSON layers - schema_version stays 4: additive optional fields, older binaries ignore unknown keys, data self-heals on the next --api refresh - docs and tests updated accordingly [skip ci] --- docs/commands/list.md | 12 +-- docs/json-api.md | 17 ++-- docs/schema-migration.md | 13 +-- src/api/usage.zig | 18 ++-- src/cli/json_output.zig | 37 +++----- src/cli/list_details.zig | 166 ++++++++++++++------------------- src/cli/render.zig | 14 +-- src/registry/account_ops.zig | 8 +- src/registry/common.zig | 119 +++++++++-------------- src/registry/import.zig | 2 +- src/registry/parse.zig | 78 ++++++++++------ src/registry/root.zig | 7 +- src/registry/storage.zig | 2 +- src/registry/storage_parse.zig | 2 +- src/workflows/live_display.zig | 8 +- src/workflows/results.zig | 39 +++----- src/workflows/usage.zig | 14 +-- tests/api_usage_test.zig | 16 ++-- tests/cli_picker_test.zig | 14 +-- tests/registry_test.zig | 10 +- tests/workflows_live_test.zig | 4 +- 21 files changed, 256 insertions(+), 344 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 1d414627..4e0f7d47 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -27,12 +27,10 @@ codex-auth list --json - `--skip-api` forbids remote API calls for this command. - `--live` keeps refreshing the terminal view and requires a TTY. - In live tables, `*` before the number marks the active account and `!` marks a refresh error. -- Live account details show the earliest future quota reset, access-token expiry, - and the expiry timestamp for each reset-credit card returned by the server. The - `NEXT RESET LOCAL` and `ACCESS EXP LOCAL` columns use local time. Credit expiry - values include their timezone (`UTC` for the server's `Z` timestamps, or the - returned numeric offset). The JSON output retains the - complete card records, including status, grant date, ID, type, and description. +- Live account details show the access-token expiry and, per account, the + available reset-credit count with the earliest card expiry. The + `ACCESS EXPIRE` and `RESET CREDITS` columns render in local time; the + available reset credits are also expandable per account in the live view. - Access-token expiry is shown in local time and is not the account or subscription expiry; refreshing credentials can extend it. Plan expiry is shown as unavailable because the current data sources do not provide it. Server card lists may be @@ -50,7 +48,7 @@ When local-only refresh is active, only the active account can be updated from l - Usage cells show remaining percent and reset time when that data is known. - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. - `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. -- JSON output includes the returned reset-credit details and `next_reset_at`; the latter is the earliest future 5-hour or weekly reset. +- JSON output includes the returned reset-credit details. - Remote refresh failures can render row overlays such as `401`, `403`, `TimedOut`, or `MissingAuth`. - `LAST ACTIVITY` is based on the last stored usage update time. - `--json` returns accounts in the same display order and includes the same row numbers shown by the table. diff --git a/docs/json-api.md b/docs/json-api.md index e03cc530..130dd22b 100644 --- a/docs/json-api.md +++ b/docs/json-api.md @@ -46,7 +46,7 @@ shortcut and is rejected when combined with `--json`. "account_name": null, "plan": "business", "auth_mode": "chatgpt", - "auth_expires_at": null, + "access_token_expires_at": null, "active": true, "created_at": 1730000000, "last_used_at": 1730001000, @@ -65,8 +65,7 @@ shortcut and is rejected when combined with `--json`. "balance": null }, "reset_credits": null, - "reset_credit_details": null, - "next_reset_at": 1730010000, + "reset_credit": null, "refresh": { "requested": true, "method": "api", @@ -79,12 +78,10 @@ shortcut and is rejected when combined with `--json`. ``` `reset_credits` remains the backward-compatible available-count field. -`reset_credit_details`, when present, contains the backend's returned reset -credit records, including each record's `id`, `reset_type`, `status`, -`granted_at`, optional `expires_at`, `title`, and `description`. The backend -may cap the returned list; compare `available_count` with `credits.length` -before presenting it as exhaustive. `next_reset_at` is the earliest future -reset timestamp from `primary.resets_at` and `secondary.resets_at`. +`reset_credit`, when present, lists the available reset-credit cards with +their `id`, `status`, `expires_at`, and `title`. The backend may cap the +returned list, so treat it as non-exhaustive. All expiry timestamps in the +terminal views render in local time. The API currently does not expose a verifiable subscription-plan expiration timestamp, so the JSON contract intentionally does not label token expiry as @@ -94,7 +91,7 @@ plan expiry. an ephemeral display selector valid only for the ordering returned by the current invocation. Empty aliases and account names are `null`. -`auth_expires_at` is the Unix timestamp when the stored access token expires. +`access_token_expires_at` is the Unix timestamp when the stored access token expires. It describes credential lifetime only; it is not a subscription or account entitlement expiration timestamp. diff --git a/docs/schema-migration.md b/docs/schema-migration.md index 2dedf99d..906e4eeb 100644 --- a/docs/schema-migration.md +++ b/docs/schema-migration.md @@ -10,18 +10,18 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r ## Current Policy -- The current persisted format is `schema_version = 5`. -- The compatibility guarantee for this release is migration from released schema `4` to the final schema `5` format. +- The current persisted format is `schema_version = 4`. +- The compatibility guarantee for this release is migration from released schema `3` to the final schema `4` format. - The legacy `version = 2` loader remains available, but it is not the compatibility boundary for this change. -- The current binary accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them to normalized `schema_version = 5`. +- The current binary accepts current-layout files that still use the old top-level key `version = 3`, or still carry the old global `last_attributed_rollout` shape, and rewrites them to normalized `schema_version = 4`. - If the binary sees a newer `schema_version` than it understands, it fails with `UnsupportedRegistryVersion` and must not write the file. ## Upgrade Behavior - User-visible behavior is always “upgrade directly to the latest supported schema”. - Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps. -- The guaranteed automatic migration is `schema_version = 4 -> schema_version = 5`; older supported layouts are rewritten directly to schema `5`. -- The existing legacy `version = 2` loader also rewrites directly to schema `5`. +- The guaranteed automatic migration is `schema_version = 3 -> schema_version = 4`; older supported layouts are rewritten directly to schema `4`. +- The existing legacy `version = 2` loader also rewrites directly to schema `4`. - Users are not expected to install intermediate `codex-auth` versions. ## Schema History @@ -47,9 +47,6 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - Legacy `team` becomes `business` - Legacy `business` becomes `enterprise` - The same conversion applies to per-account `last_usage.plan_type` -- `schema_version = 5` - - Stores the access-token expiry timestamp for each account when the token exposes a JWT `exp` claim - - Stores reset-credit detail records returned by the reset-credit endpoint alongside the available count ## When To Bump `schema_version` diff --git a/src/api/usage.zig b/src/api/usage.zig index c37136b5..d62b6adb 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -12,7 +12,7 @@ pub const UsageFetchResult = struct { status_code: ?u16, error_code: ?ResponseErrorCode = null, missing_auth: bool = false, - auth_expires_at: ?i64 = null, + access_token_expires_at: ?i64 = null, }; pub const max_response_error_code_bytes: usize = 64; @@ -32,7 +32,7 @@ pub const BatchUsageFetchResult = struct { error_code: ?ResponseErrorCode = null, missing_auth: bool = false, error_name: ?[]const u8 = null, - auth_expires_at: ?i64 = null, + access_token_expires_at: ?i64 = null, pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { if (self.snapshot) |*snapshot| { @@ -79,7 +79,7 @@ pub fn fetchUsageForAuthPathDetailed(allocator: std.mem.Allocator, auth_path: [] const chatgpt_account_id = info.chatgpt_account_id orelse return .{ .snapshot = null, .status_code = null, .missing_auth = true }; var result = try fetchUsageForTokenDetailed(allocator, default_usage_endpoint, access_token, chatgpt_account_id); - result.auth_expires_at = auth.accessTokenExpiresAt(allocator, access_token) catch null; + result.access_token_expires_at = auth.accessTokenExpiresAt(allocator, access_token) catch null; return result; } @@ -110,7 +110,7 @@ pub fn fetchUsageForAuthPathsDetailedBatch( continue; }; defer info.deinit(arena); - results[idx].auth_expires_at = info.access_token_expires_at; + results[idx].access_token_expires_at = info.access_token_expires_at; if (info.auth_mode == .apikey) { continue; @@ -201,7 +201,7 @@ pub fn fetchUsageForAuthPathsDetailedBatch( const http_result = reset_credit_http_results.items[unique_idx]; if (http_result.outcome != .ok or http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) continue; const details = parseResetCreditResponse(allocator, http_result.body) catch null; - if (details) |value| results[result_idx].snapshot.?.reset_credit_details = value; + if (details) |value| results[result_idx].snapshot.?.reset_credit = value; } return results; @@ -237,7 +237,7 @@ pub fn fetchUsageForTokenDetailed( if (snapshot) |*value| { errdefer registry.freeRateLimitSnapshot(allocator, value); if (fetchResetCreditDetailsForToken(allocator, access_token, account_id) catch null) |details| { - value.reset_credit_details = details; + value.reset_credit = details; } } return .{ @@ -251,7 +251,7 @@ fn fetchResetCreditDetailsForToken( allocator: std.mem.Allocator, access_token: []const u8, account_id: []const u8, -) !?registry.RateLimitResetCredits { +) !?[]registry.ResetCreditCard { const http_result = try chatgpt_http.runGetJsonCommand(allocator, default_reset_credits_endpoint, access_token, account_id); defer allocator.free(http_result.body); if (http_result.body.len == 0 or isNonSuccessStatus(http_result.status_code)) return null; @@ -350,10 +350,10 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi return snapshot; } -pub fn parseResetCreditResponse(allocator: std.mem.Allocator, body: []const u8) !?registry.RateLimitResetCredits { +pub fn parseResetCreditResponse(allocator: std.mem.Allocator, body: []const u8) !?[]registry.ResetCreditCard { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .parse_numbers = false }); defer parsed.deinit(); - return try registry_parse.parseResetCreditDetails(allocator, parsed.value); + return try registry_parse.parseResetCreditList(allocator, parsed.value); } fn parseResetCredits(v: std.json.Value) ?i64 { diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index 92efadc9..0a5bd6ae 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -166,8 +166,8 @@ fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !v try writeOptionalText(jw, planName(account.plan)); try jw.objectField("auth_mode"); try writeOptionalText(jw, authModeName(account.auth_mode)); - try jw.objectField("auth_expires_at"); - try jw.write(account.auth_expires_at); + try jw.objectField("access_token_expires_at"); + try jw.write(account.access_token_expires_at); try jw.objectField("active"); try jw.write(account.active); try jw.objectField("created_at"); @@ -193,50 +193,35 @@ fn writeUsage(jw: *std.json.Stringify, usage: *const results.UsageView) !void { try writeCredits(jw, usage.credits); try jw.objectField("reset_credits"); try jw.write(usage.reset_credits); - try jw.objectField("reset_credit_details"); - try writeResetCreditDetails(jw, usage.reset_credit_details); - try jw.objectField("next_reset_at"); - try jw.write(usage.next_reset_at); + try jw.objectField("reset_credit"); + try writeResetCreditList(jw, usage.reset_credit); try jw.objectField("refresh"); try writeUsageRefresh(jw, &usage.refresh); try jw.endObject(); } -fn writeResetCreditDetails( +fn writeResetCreditList( jw: *std.json.Stringify, - details: ?registry.RateLimitResetCredits, + details: ?[]registry.ResetCreditCard, ) !void { const value = details orelse { try jw.write(null); return; }; - try jw.beginObject(); - try jw.objectField("available_count"); - try jw.write(value.available_count); - try jw.objectField("total_earned_count"); - try jw.write(value.total_earned_count); - try jw.objectField("credits"); try jw.beginArray(); - for (value.credits) |credit| { + for (value) |card| { try jw.beginObject(); try jw.objectField("id"); - try jw.write(credit.id); - try jw.objectField("reset_type"); - try jw.write(credit.reset_type); + try jw.write(card.id); try jw.objectField("status"); - try jw.write(credit.status); - try jw.objectField("granted_at"); - try jw.write(credit.granted_at); + try jw.write(card.status); try jw.objectField("expires_at"); - try jw.write(credit.expires_at); + try jw.write(card.expires_at); try jw.objectField("title"); - try jw.write(credit.title); - try jw.objectField("description"); - try jw.write(credit.description); + try jw.write(card.title); try jw.endObject(); } try jw.endArray(); - try jw.endObject(); } fn usageSourceName(source: results.UsageSource) []const u8 { diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index fcaf1bbc..8c18e841 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -1,24 +1,20 @@ const std = @import("std"); const registry = @import("../registry/root.zig"); -const app_runtime = @import("../core/runtime.zig"); const rows = @import("rows.zig"); const style = @import("style.zig"); const table_layout = @import("table_layout.zig"); -const max_columns = 64; -const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "NEXT RESET LOCAL", "LAST", "ACCESS EXP LOCAL" }; -const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 16, 10, 16 }; -const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 10, 5, 10 }; +const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST", "ACCESS EXPIRE", "RESET CREDITS" }; +const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 10, 16, 14 }; +const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 5, 10, 8 }; const base_column_count = base_headers.len; -const credit_width: usize = 23; const separator_width: usize = 3; -pub fn writeHeader(writer: *style.StyledWriter, reg: *const registry.Registry, number_width: usize, max_cols: ?usize) !void { +pub fn writeHeader(writer: *style.StyledWriter, number_width: usize, max_cols: ?usize) !void { const visible_base = visibleBaseColumns(max_cols, number_width); - const card_count = visibleCardCount(maxCardCount(reg), max_cols, visible_base, number_width); - var values: [max_columns][]const u8 = undefined; - var widths: [max_columns]usize = undefined; - var minimums: [max_columns]usize = undefined; + var values: [base_column_count][]const u8 = undefined; + var widths: [base_column_count]usize = undefined; + var minimums: [base_column_count]usize = undefined; var count: usize = 0; for (base_headers, 0..) |value, index| { if (!visible_base[index]) continue; @@ -27,14 +23,6 @@ pub fn writeHeader(writer: *style.StyledWriter, reg: *const registry.Registry, n minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; count += 1; } - var labels: [max_columns][16]u8 = undefined; - for (0..card_count) |index| { - const label = std.fmt.bufPrint(&labels[index], "C{d} EXP", .{index + 1}) catch "C EXP"; - values[count] = label; - widths[count] = credit_width; - minimums[count] = credit_width; - count += 1; - } try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, false, style.role.status, true); } @@ -44,25 +32,20 @@ pub fn write( account: registry.AccountRecord, row: anytype, number_width: usize, - max_cards: usize, max_cols: ?usize, ) !void { const visible_base = visibleBaseColumns(max_cols, number_width); - const card_count = visibleCardCount(max_cards, max_cols, visible_base, number_width); - const next_reset = try nextResetText(allocator, account.last_usage); - defer allocator.free(next_reset); - const token_expiry = try rows.formatTimestampAlloc(allocator, account.auth_expires_at); + const token_expiry = try rows.formatTimestampAlloc(allocator, account.access_token_expires_at); defer allocator.free(token_expiry); + const reset_credits_cell = try resetCreditsCell(allocator, account); + defer allocator.free(reset_credits_cell); const number = row.number; - var values: [max_columns][]const u8 = undefined; - var widths: [max_columns]usize = undefined; - var minimums: [max_columns]usize = undefined; - var owned_expiries: [max_columns][]u8 = undefined; - var owned_expiry_count: usize = 0; - defer for (owned_expiries[0..owned_expiry_count]) |expiry| allocator.free(expiry); + var values: [base_column_count][]const u8 = undefined; + var widths: [base_column_count]usize = undefined; + var minimums: [base_column_count]usize = undefined; var count: usize = 0; - const base_values = [_][]const u8{ number, row.account, row.plan, row.rate_5h, row.rate_week, next_reset, row.last, token_expiry }; + const base_values = [_][]const u8{ number, row.account, row.plan, row.rate_5h, row.rate_week, row.last, token_expiry, reset_credits_cell }; for (base_values, 0..) |value, index| { if (!visible_base[index]) continue; values[count] = value; @@ -70,27 +53,30 @@ pub fn write( minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; count += 1; } - for (0..card_count) |index| { - const expiry = try creditExpiryAlloc(allocator, account.last_usage, index); - owned_expiries[owned_expiry_count] = expiry; - owned_expiry_count += 1; - values[count] = expiry; - widths[count] = credit_width; - minimums[count] = credit_width; - count += 1; - } const row_style = if (number.len != 0 and number[0] == '!') style.role.error_text else if (number.len != 0 and number[0] == '*') style.role.success else ""; try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, true, row_style, false); } -fn maxCardCount(reg: *const registry.Registry) usize { - var result: usize = 0; - for (reg.accounts.items) |account| { - if (account.last_usage) |usage| { - if (usage.reset_credit_details) |details| result = @max(result, details.credits.len); +/// `N` from the persisted `reset_credits` count, plus the earliest expiry among +/// the available cards in local `MMM-DD` form (omitted when unknown). +fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecord) ![]u8 { + const usage = account.last_usage orelse return std.fmt.allocPrint(allocator, "-", .{}); + const count = usage.reset_credits orelse return std.fmt.allocPrint(allocator, "-", .{}); + var earliest: ?i64 = null; + if (usage.reset_credit) |cards| { + for (cards) |card| { + if (!std.mem.eql(u8, card.status, "available")) continue; + if (parseIsoEpoch(card.expires_at orelse "")) |epoch| { + if (earliest == null or epoch < earliest.?) earliest = epoch; + } } } - return result; + if (earliest) |epoch| { + const local = try rows.formatTimestampAlloc(allocator, epoch); + defer allocator.free(local); + if (local.len >= 10) return std.fmt.allocPrint(allocator, "{d} (exp {s})", .{ count, local[5..10] }); + } + return std.fmt.allocPrint(allocator, "{d}", .{count}); } fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count]bool { @@ -98,7 +84,7 @@ fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count] for (&visible) |*value| value.* = true; const limit = max_cols orelse return visible; const drop_order = [_]usize{ 7, 6, 5, 2 }; - while (minimumTableWidth(visible, 0, number_width) > limit) { + while (minimumTableWidth(visible, number_width) > limit) { var changed = false; for (drop_order) |index| { if (visible[index]) { @@ -112,23 +98,14 @@ fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count] return visible; } -fn visibleCardCount(max_cards: usize, max_cols: ?usize, visible_base: [base_column_count]bool, number_width: usize) usize { - var count = @min(max_cards, max_columns - base_column_count); - const limit = max_cols orelse return count; - while (count > 0 and minimumTableWidth(visible_base, count, number_width) > limit) : (count -= 1) {} - return count; -} - -fn minimumTableWidth(visible_base: [base_column_count]bool, card_count: usize, number_width: usize) usize { +fn minimumTableWidth(visible: [base_column_count]bool, number_width: usize) usize { var total: usize = 0; var column_count: usize = 0; for (base_minimum_widths, 0..) |width, index| { - if (!visible_base[index]) continue; + if (!visible[index]) continue; total += if (index == 0) @max(width, number_width) else width; column_count += 1; } - total += card_count * credit_width; - column_count += card_count; return if (column_count == 0) 0 else total + (column_count - 1) * separator_width; } @@ -142,7 +119,7 @@ fn writeAlignedRow( row_style: []const u8, header: bool, ) !void { - var widths: [max_columns]usize = undefined; + var widths: [base_column_count]usize = undefined; for (desired, 0..) |width, index| widths[index] = width; if (max_cols) |limit| { var total = minimumForWidths(widths[0..values.len]); @@ -196,45 +173,42 @@ fn minimumForWidths(widths: []const usize) usize { return total + (widths.len - 1) * separator_width; } -fn nextResetText(allocator: std.mem.Allocator, snapshot: ?registry.RateLimitSnapshot) ![]u8 { - var next: ?i64 = null; - const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - if (snapshot) |usage| { - for ([_]?registry.RateLimitWindow{ usage.primary, usage.secondary }) |maybe_window| { - const window = maybe_window orelse continue; - const timestamp = window.resets_at orelse continue; - if (timestamp <= now) continue; - if (next == null or timestamp < next.?) next = timestamp; - } - } - return rows.formatTimestampAlloc(allocator, next); -} - -fn creditExpiryAlloc(allocator: std.mem.Allocator, snapshot: ?registry.RateLimitSnapshot, index: usize) ![]u8 { - const usage = snapshot orelse return allocator.dupe(u8, "-"); - const details = usage.reset_credit_details orelse return allocator.dupe(u8, "-"); - if (index >= details.credits.len) return allocator.dupe(u8, "-"); - const expiry = details.credits[index].expires_at orelse return allocator.dupe(u8, "-"); - if (expiry.len < 19 or expiry[10] != 'T') return std.fmt.allocPrint(allocator, "{s} TZ?", .{expiry}); +/// Minimal ISO-8601 parser for the backend shape `YYYY-MM-DDTHH:MM:SS` with an +/// optional trailing zone (`Z` or `±HH:MM`). Returns epoch seconds, or null. +fn parseIsoEpoch(value: []const u8) ?i64 { + if (value.len < 19 or value[4] != '-' or value[7] != '-' or value[10] != 'T' or + value[13] != ':' or value[16] != ':') return null; + const year = std.fmt.parseInt(i64, value[0..4], 10) catch return null; + const month = std.fmt.parseInt(i64, value[5..7], 10) catch return null; + const day = std.fmt.parseInt(i64, value[8..10], 10) catch return null; + const hour = std.fmt.parseInt(i64, value[11..13], 10) catch return null; + const minute = std.fmt.parseInt(i64, value[14..16], 10) catch return null; + const second = std.fmt.parseInt(i64, value[17..19], 10) catch return null; + if (month < 1 or month > 12 or day < 1 or day > 31) return null; - var date_time: [19]u8 = undefined; - @memcpy(&date_time, expiry[0..19]); - date_time[10] = ' '; - - var zone: []const u8 = "TZ?"; - if (expiry.len > 19) { - if (expiry[expiry.len - 1] == 'Z' or expiry[expiry.len - 1] == 'z') { - zone = "UTC"; - } else { - var offset_start: ?usize = null; - for (expiry[19..], 19..) |value, offset| { - if (value == '+' or value == '-') { - offset_start = offset; - break; - } + var epoch = daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second; + if (value.len > 19) { + const zone_marker = value[19]; + if (zone_marker == '+' or zone_marker == '-') { + if (value.len >= 25 and value[22] == ':') { + const zone_hour = std.fmt.parseInt(i64, value[20..22], 10) catch 0; + const zone_minute = std.fmt.parseInt(i64, value[23..25], 10) catch 0; + const offset = zone_hour * 3600 + zone_minute * 60; + epoch += if (zone_marker == '+') -offset else offset; } - if (offset_start) |start| zone = expiry[start..]; } } - return std.fmt.allocPrint(allocator, "{s} {s}", .{ date_time[0..], zone }); + return epoch; +} + +/// Howard Hinnant's days_from_civil algorithm. +fn daysFromCivil(year: i64, month: i64, day: i64) i64 { + var y = year; + if (month <= 2) y -= 1; + const era = @divFloor(y, 400); + const yoe = y - era * 400; + const mp = @mod(month + 9, 12); + const doy = @divFloor(153 * mp + 2, 5) + day - 1; + const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; + return era * 146097 + doe - 719468; } diff --git a/src/cli/render.zig b/src/cli/render.zig index 49f1283d..64800dc2 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -424,17 +424,9 @@ pub fn renderListBody( ) !void { _ = widths; const details = @import("list_details.zig"); - const max_cards = blk: { - var count: usize = 0; - for (reg.accounts.items) |account| { - if (account.last_usage) |usage| { - if (usage.reset_credit_details) |credits| count = @max(count, credits.credits.len); - } - } - break :blk count; - }; + const number_width = idx_width + 1; - try details.writeHeader(writer, reg, number_width, max_cols); + try details.writeHeader(writer, number_width, max_cols); var displayed_counter: usize = 0; for (rows) |row| { if (row.is_header) continue; @@ -452,7 +444,7 @@ pub fn renderListBody( .rate_5h = row.rate_5h, .rate_week = row.rate_week, .last = row.last, - }, number_width, max_cards, max_cols); + }, number_width, max_cols); } } diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index 94659355..cbe9c3b2 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -192,10 +192,10 @@ pub fn syncActiveAccountFromAuthWithImporter(allocator: std.mem.Allocator, codex changed = true; } reg.accounts.items[idx].auth_mode = info.auth_mode; - if (reg.accounts.items[idx].auth_expires_at != info.access_token_expires_at) { + if (reg.accounts.items[idx].access_token_expires_at != info.access_token_expires_at) { changed = true; } - reg.accounts.items[idx].auth_expires_at = info.access_token_expires_at; + reg.accounts.items[idx].access_token_expires_at = info.access_token_expires_at; const dest = try accountAuthPath(allocator, codex_home, rec_account_key); defer allocator.free(dest); @@ -605,7 +605,7 @@ pub fn accountFromAuth( .account_name = null, .plan = info.plan, .auth_mode = info.auth_mode, - .auth_expires_at = info.access_token_expires_at, + .access_token_expires_at = info.access_token_expires_at, .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = null, .last_usage = null, @@ -643,7 +643,7 @@ pub fn accountFromApiKeyMe( .account_name = owned_account_name, .plan = null, .auth_mode = .apikey, - .auth_expires_at = null, + .access_token_expires_at = null, .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = null, .last_usage = null, diff --git a/src/registry/common.zig b/src/registry/common.zig index 4871f4d0..2f6b34dd 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -8,7 +8,7 @@ const c_time = @cImport({ pub const PlanType = enum { free, go, plus, prolite, pro, business, enterprise, edu, unknown }; pub const AuthMode = enum { chatgpt, apikey }; -pub const current_schema_version: u32 = 5; +pub const current_schema_version: u32 = 4; pub const min_supported_schema_version: u32 = 2; pub const private_file_permissions: std.Io.File.Permissions = switch (builtin.os.tag) { .windows => .default_file, @@ -64,20 +64,11 @@ pub const CreditsSnapshot = struct { balance: ?[]u8, }; -pub const RateLimitResetCredit = struct { +pub const ResetCreditCard = struct { id: []u8, - reset_type: []u8, status: []u8, - granted_at: []u8, expires_at: ?[]u8, title: ?[]u8, - description: ?[]u8, -}; - -pub const RateLimitResetCredits = struct { - available_count: i64, - total_earned_count: ?i64, - credits: []RateLimitResetCredit, }; pub const RateLimitSnapshot = struct { @@ -85,7 +76,7 @@ pub const RateLimitSnapshot = struct { secondary: ?RateLimitWindow, credits: ?CreditsSnapshot, reset_credits: ?i64 = null, - reset_credit_details: ?RateLimitResetCredits = null, + reset_credit: ?[]ResetCreditCard = null, plan_type: ?PlanType, }; @@ -116,7 +107,7 @@ pub const AccountRecord = struct { account_name: ?[]u8, plan: ?PlanType, auth_mode: ?AuthMode, - auth_expires_at: ?i64 = null, + access_token_expires_at: ?i64 = null, created_at: i64, last_used_at: ?i64, last_usage: ?RateLimitSnapshot, @@ -195,20 +186,17 @@ pub fn freeRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: *const Rate if (snapshot.credits) |*c| { if (c.balance) |b| allocator.free(b); } - if (snapshot.reset_credit_details) |details| { - for (details.credits) |credit| freeRateLimitResetCredit(allocator, credit); - allocator.free(details.credits); + if (snapshot.reset_credit) |cards| { + for (cards) |card| freeResetCreditCard(allocator, card); + allocator.free(cards); } } -pub fn freeRateLimitResetCredit(allocator: std.mem.Allocator, credit: RateLimitResetCredit) void { - allocator.free(credit.id); - allocator.free(credit.reset_type); - allocator.free(credit.status); - allocator.free(credit.granted_at); - if (credit.expires_at) |value| allocator.free(value); - if (credit.title) |value| allocator.free(value); - if (credit.description) |value| allocator.free(value); +pub fn freeResetCreditCard(allocator: std.mem.Allocator, card: ResetCreditCard) void { + allocator.free(card.id); + allocator.free(card.status); + if (card.expires_at) |value| allocator.free(value); + if (card.title) |value| allocator.free(value); } pub fn freeRolloutSignature(allocator: std.mem.Allocator, signature: *const RolloutSignature) void { @@ -245,9 +233,9 @@ pub fn cloneRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: RateLimitS if (credits.balance) |balance| allocator.free(balance); }; - var cloned_reset_credit_details: ?RateLimitResetCredits = null; - if (snapshot.reset_credit_details) |details| { - cloned_reset_credit_details = try cloneRateLimitResetCredits(allocator, details); + var cloned_reset_credit: ?[]ResetCreditCard = null; + if (snapshot.reset_credit) |cards| { + cloned_reset_credit = try cloneResetCreditCards(allocator, cards); } return .{ @@ -255,51 +243,26 @@ pub fn cloneRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: RateLimitS .secondary = snapshot.secondary, .credits = cloned_credits, .reset_credits = snapshot.reset_credits, - .reset_credit_details = cloned_reset_credit_details, + .reset_credit = cloned_reset_credit, .plan_type = snapshot.plan_type, }; } -pub fn cloneRateLimitResetCredits(allocator: std.mem.Allocator, details: RateLimitResetCredits) !RateLimitResetCredits { - var cloned_credits_list = try allocator.alloc(RateLimitResetCredit, details.credits.len); - errdefer allocator.free(cloned_credits_list); +pub fn cloneResetCreditCards(allocator: std.mem.Allocator, cards: []const ResetCreditCard) ![]ResetCreditCard { + var cloned = try allocator.alloc(ResetCreditCard, cards.len); + errdefer allocator.free(cloned); var cloned_count: usize = 0; - errdefer for (cloned_credits_list[0..cloned_count]) |credit| freeRateLimitResetCredit(allocator, credit); - for (details.credits, 0..) |credit, idx| { - cloned_credits_list[idx] = try cloneRateLimitResetCredit(allocator, credit); + errdefer for (cloned[0..cloned_count]) |card| freeResetCreditCard(allocator, card); + for (cards, 0..) |card, idx| { + cloned[idx] = .{ + .id = try allocator.dupe(u8, card.id), + .status = try allocator.dupe(u8, card.status), + .expires_at = if (card.expires_at) |value| try allocator.dupe(u8, value) else null, + .title = if (card.title) |value| try allocator.dupe(u8, value) else null, + }; cloned_count += 1; } - return .{ - .available_count = details.available_count, - .total_earned_count = details.total_earned_count, - .credits = cloned_credits_list, - }; -} - -fn cloneRateLimitResetCredit(allocator: std.mem.Allocator, credit: RateLimitResetCredit) !RateLimitResetCredit { - const id = try allocator.dupe(u8, credit.id); - errdefer allocator.free(id); - const reset_type = try allocator.dupe(u8, credit.reset_type); - errdefer allocator.free(reset_type); - const status = try allocator.dupe(u8, credit.status); - errdefer allocator.free(status); - const granted_at = try allocator.dupe(u8, credit.granted_at); - errdefer allocator.free(granted_at); - const expires_at = try cloneOptionalStringAlloc(allocator, credit.expires_at); - errdefer if (expires_at) |value| allocator.free(value); - const title = try cloneOptionalStringAlloc(allocator, credit.title); - errdefer if (title) |value| allocator.free(value); - const description = try cloneOptionalStringAlloc(allocator, credit.description); - errdefer if (description) |value| allocator.free(value); - return .{ - .id = id, - .reset_type = reset_type, - .status = status, - .granted_at = granted_at, - .expires_at = expires_at, - .title = title, - .description = description, - }; + return cloned; } pub fn setRolloutSignature( @@ -344,23 +307,25 @@ pub fn rateLimitSnapshotEqual(a: RateLimitSnapshot, b: RateLimitSnapshot) bool { rateLimitWindowEqual(a.secondary, b.secondary) and creditsEqual(a.credits, b.credits) and a.reset_credits == b.reset_credits and - resetCreditDetailsEqual(a.reset_credit_details, b.reset_credit_details) and + resetCreditEqual(a.reset_credit, b.reset_credit) and a.plan_type == b.plan_type; } -pub fn resetCreditDetailsEqual(a: ?RateLimitResetCredits, b: ?RateLimitResetCredits) bool { +pub fn resetCreditEqual(a: ?[]const ResetCreditCard, b: ?[]const ResetCreditCard) bool { if (a == null and b == null) return true; if (a == null or b == null) return false; - if (a.?.available_count != b.?.available_count or a.?.total_earned_count != b.?.total_earned_count) return false; - if (a.?.credits.len != b.?.credits.len) return false; - for (a.?.credits, b.?.credits) |left, right| { - if (!optionalStringEqual(left.id, right.id) or - !optionalStringEqual(left.reset_type, right.reset_type) or - !optionalStringEqual(left.status, right.status) or - !optionalStringEqual(left.granted_at, right.granted_at) or - !optionalStringEqual(left.expires_at, right.expires_at) or - !optionalStringEqual(left.title, right.title) or - !optionalStringEqual(left.description, right.description)) return false; + if (a.?.len != b.?.len) return false; + for (a.?, b.?) |left, right| { + if (!std.mem.eql(u8, left.id, right.id) or + !std.mem.eql(u8, left.status, right.status)) return false; + if ((left.expires_at == null) != (right.expires_at == null)) return false; + if (left.expires_at) |l| { + if (!std.mem.eql(u8, l, right.expires_at.?)) return false; + } + if ((left.title == null) != (right.title == null)) return false; + if (left.title) |l| { + if (!std.mem.eql(u8, l, right.title.?)) return false; + } } return true; } diff --git a/src/registry/import.zig b/src/registry/import.zig index 1b556848..697f4acd 100644 --- a/src/registry/import.zig +++ b/src/registry/import.zig @@ -610,7 +610,7 @@ fn syncCurrentAuthBestEffort( } reg.accounts.items[idx].plan = info.plan; reg.accounts.items[idx].auth_mode = info.auth_mode; - reg.accounts.items[idx].auth_expires_at = info.access_token_expires_at; + reg.accounts.items[idx].access_token_expires_at = info.access_token_expires_at; } else { var record = try accountFromAuth(allocator, "", &info); errdefer freeAccountRecord(allocator, &record); diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 2fcd4d68..7d745326 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -9,7 +9,7 @@ const RateLimitWindow = common.RateLimitWindow; const RolloutSignature = common.RolloutSignature; const CreditsSnapshot = common.CreditsSnapshot; const RateLimitResetCredit = common.RateLimitResetCredit; -const RateLimitResetCredits = common.RateLimitResetCredits; +const ResetCreditCard = common.ResetCreditCard; pub fn normalizePlanType(s: []const u8) PlanType { if (std.ascii.eqlIgnoreCase(s, "free")) return .free; @@ -52,7 +52,7 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_versio .object => |o| o, else => return null, }; - var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .reset_credit_details = null, .plan_type = null }; + var snap = RateLimitSnapshot{ .primary = null, .secondary = null, .credits = null, .reset_credits = null, .reset_credit = null, .plan_type = null }; if (obj.get("plan_type")) |p| { switch (p) { @@ -64,45 +64,58 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_versio if (obj.get("secondary")) |p| snap.secondary = parseWindow(p); if (obj.get("credits")) |c| snap.credits = parseCredits(allocator, c); snap.reset_credits = readInt(obj.get("reset_credits")); - if (obj.get("reset_credit_details")) |details| { - snap.reset_credit_details = parseResetCreditDetails(allocator, details) catch null; + if (obj.get("reset_credit")) |details| { + snap.reset_credit = parseResetCreditList(allocator, details) catch null; } return snap; } -pub fn parseResetCreditDetails(allocator: std.mem.Allocator, v: std.json.Value) !?RateLimitResetCredits { +pub fn parseResetCreditList(allocator: std.mem.Allocator, v: std.json.Value) !?[]ResetCreditCard { const obj = switch (v) { .object => |o| o, else => return null, }; - const available_count = readInt(obj.get("available_count")) orelse return null; - const total_earned_count = readInt(obj.get("total_earned_count")); - var credits = std.ArrayList(RateLimitResetCredit).empty; + const credits_value = obj.get("credits") orelse return null; + const items = switch (credits_value) { + .array => |array| array.items, + else => return null, + }; + var cards = std.ArrayList(ResetCreditCard).empty; errdefer { - for (credits.items) |credit| common.freeRateLimitResetCredit(allocator, credit); - credits.deinit(allocator); + for (cards.items) |card| common.freeResetCreditCard(allocator, card); + cards.deinit(allocator); } - if (obj.get("credits")) |credits_value| { - const items = switch (credits_value) { - .array => |array| array.items, - else => return null, + for (items) |item| { + const item_obj = switch (item) { + .object => |item_obj| item_obj, + else => continue, + }; + const card = try parseResetCreditCard(allocator, item_obj) orelse continue; + cards.append(allocator, card) catch |err| { + common.freeResetCreditCard(allocator, card); + return err; }; - for (items) |item| { - const item_obj = switch (item) { - .object => |item_obj| item_obj, - else => continue, - }; - const credit = try parseResetCredit(allocator, item_obj) orelse continue; - credits.append(allocator, credit) catch |err| { - common.freeRateLimitResetCredit(allocator, credit); - return err; - }; - } } + return try cards.toOwnedSlice(allocator); +} + +fn parseResetCreditCard(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !?ResetCreditCard { + const id = try requiredStringAlloc(allocator, obj.get("id")) orelse return null; + errdefer allocator.free(id); + const status = try requiredStringAlloc(allocator, obj.get("status")) orelse { + allocator.free(id); + return null; + }; + errdefer allocator.free(status); + const expires_at = optionalStringAlloc(allocator, obj.get("expires_at")); + errdefer if (expires_at) |value| allocator.free(value); + const title = optionalStringAlloc(allocator, obj.get("title")); + errdefer if (title) |value| allocator.free(value); return .{ - .available_count = available_count, - .total_earned_count = total_earned_count, - .credits = try credits.toOwnedSlice(allocator), + .id = id, + .status = status, + .expires_at = expires_at, + .title = title, }; } @@ -144,6 +157,15 @@ fn parseResetCredit(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !?Rat }; } +fn optionalStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) ?[]u8 { + const text = switch (value orelse return null) { + .string => |s| s, + else => return null, + }; + if (text.len == 0) return null; + return allocator.dupe(u8, text) catch null; +} + fn requiredStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]u8 { const text = switch (value orelse return null) { .string => |s| s, diff --git a/src/registry/root.zig b/src/registry/root.zig index a2225bf9..2a18040d 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -23,8 +23,7 @@ pub const realPathAlloc = common.realPathAlloc; pub const readFileAlloc = common.readFileAlloc; pub const RateLimitWindow = common.RateLimitWindow; pub const CreditsSnapshot = common.CreditsSnapshot; -pub const RateLimitResetCredit = common.RateLimitResetCredit; -pub const RateLimitResetCredits = common.RateLimitResetCredits; +pub const ResetCreditCard = common.ResetCreditCard; pub const RateLimitSnapshot = common.RateLimitSnapshot; pub const RolloutSignature = common.RolloutSignature; pub const ApiConfig = common.ApiConfig; @@ -43,12 +42,12 @@ pub const defaultApiConfig = common.defaultApiConfig; pub const defaultLiveConfig = common.defaultLiveConfig; pub const freeAccountRecord = common.freeAccountRecord; pub const freeRateLimitSnapshot = common.freeRateLimitSnapshot; -pub const freeRateLimitResetCredit = common.freeRateLimitResetCredit; +pub const freeResetCreditCard = common.freeResetCreditCard; pub const freeRolloutSignature = common.freeRolloutSignature; pub const rolloutSignaturesEqual = common.rolloutSignaturesEqual; pub const cloneRolloutSignature = common.cloneRolloutSignature; pub const cloneRateLimitSnapshot = common.cloneRateLimitSnapshot; -pub const cloneRateLimitResetCredits = common.cloneRateLimitResetCredits; +pub const cloneResetCreditCards = common.cloneResetCreditCards; pub const setRolloutSignature = common.setRolloutSignature; pub const setAccountLastLocalRollout = common.setAccountLastLocalRollout; pub const rateLimitSnapshotsEqual = common.rateLimitSnapshotsEqual; diff --git a/src/registry/storage.zig b/src/registry/storage.zig index e3f14b78..201e7ccc 100644 --- a/src/registry/storage.zig +++ b/src/registry/storage.zig @@ -209,7 +209,7 @@ fn migrateLegacyRecord( .account_name = null, .plan = info.plan orelse legacy.plan, .auth_mode = info.auth_mode, - .auth_expires_at = info.access_token_expires_at, + .access_token_expires_at = info.access_token_expires_at, .created_at = legacy.created_at, .last_used_at = legacy.last_used_at, .last_usage = legacy.last_usage, diff --git a/src/registry/storage_parse.zig b/src/registry/storage_parse.zig index 3f2489d5..edfeca25 100644 --- a/src/registry/storage_parse.zig +++ b/src/registry/storage_parse.zig @@ -43,7 +43,7 @@ pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap, .account_name = try parseOptionalStoredStringAlloc(allocator, obj.get("account_name")), .plan = null, .auth_mode = null, - .auth_expires_at = readInt(obj.get("auth_expires_at")), + .access_token_expires_at = readInt(obj.get("access_token_expires_at")), .created_at = readInt(obj.get("created_at")) orelse std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = readInt(obj.get("last_used_at")), .last_usage = null, diff --git a/src/workflows/live_display.zig b/src/workflows/live_display.zig index 093487b6..f5520f8b 100644 --- a/src/workflows/live_display.zig +++ b/src/workflows/live_display.zig @@ -36,8 +36,8 @@ pub fn switchLiveUsageFieldsEqual( maybe_a: ?*const registry.AccountRecord, maybe_b: ?*const registry.AccountRecord, ) bool { - if ((if (maybe_a) |rec| rec.auth_expires_at else null) != - (if (maybe_b) |rec| rec.auth_expires_at else null)) return false; + if ((if (maybe_a) |rec| rec.access_token_expires_at else null) != + (if (maybe_b) |rec| rec.access_token_expires_at else null)) return false; const a_usage = if (maybe_a) |rec| rec.last_usage else null; const b_usage = if (maybe_b) |rec| rec.last_usage else null; if (!registry.rateLimitSnapshotsEqual(a_usage, b_usage)) return false; @@ -84,7 +84,7 @@ pub fn applySwitchLiveUsageDeltaToLatest( const latest_rec = &latest.accounts.items[latest_idx]; if (!switchLiveUsageFieldsEqual(base_rec, latest_rec)) return false; - latest_rec.auth_expires_at = refreshed_rec.auth_expires_at; + latest_rec.access_token_expires_at = refreshed_rec.access_token_expires_at; if (refreshed_rec.last_usage) |snapshot| { const cloned_snapshot = try registry.cloneRateLimitSnapshot(allocator, snapshot); registry.updateUsage(allocator, latest, refreshed_rec.account_key, cloned_snapshot); @@ -214,7 +214,7 @@ pub fn cloneAccountRecord(allocator: std.mem.Allocator, rec: *const registry.Acc .account_name = account_name, .plan = rec.plan, .auth_mode = rec.auth_mode, - .auth_expires_at = rec.auth_expires_at, + .access_token_expires_at = rec.access_token_expires_at, .created_at = rec.created_at, .last_used_at = rec.last_used_at, .last_usage = last_usage, diff --git a/src/workflows/results.zig b/src/workflows/results.zig index 85b0c19e..f09cdf85 100644 --- a/src/workflows/results.zig +++ b/src/workflows/results.zig @@ -56,15 +56,14 @@ pub const UsageView = struct { secondary: ?registry.RateLimitWindow = null, credits: ?CreditsView = null, reset_credits: ?i64 = null, - reset_credit_details: ?registry.RateLimitResetCredits = null, - next_reset_at: ?i64 = null, + reset_credit: ?[]registry.ResetCreditCard = null, refresh: UsageRefreshView, pub fn deinit(self: *UsageView, allocator: std.mem.Allocator) void { if (self.credits) |*credits| credits.deinit(allocator); - if (self.reset_credit_details) |details| { - for (details.credits) |credit| registry.freeRateLimitResetCredit(allocator, credit); - allocator.free(details.credits); + if (self.reset_credit) |cards| { + for (cards) |card| registry.freeResetCreditCard(allocator, card); + allocator.free(cards); } self.refresh.deinit(allocator); self.* = undefined; @@ -79,7 +78,7 @@ pub const AccountView = struct { account_name: ?[]u8, plan: ?registry.PlanType, auth_mode: ?registry.AuthMode, - auth_expires_at: ?i64, + access_token_expires_at: ?i64, active: bool, created_at: i64, last_used_at: ?i64, @@ -285,7 +284,7 @@ fn buildAccountView( .account_name = account_name, .plan = registry.resolveDisplayPlan(rec), .auth_mode = rec.auth_mode, - .auth_expires_at = rec.auth_expires_at, + .access_token_expires_at = rec.access_token_expires_at, .active = isActive(reg, account_idx), .created_at = rec.created_at, .last_used_at = rec.last_used_at, @@ -315,30 +314,14 @@ fn buildUsageView( .secondary = snapshot.secondary, .credits = credits, .reset_credits = snapshot.reset_credits, - .reset_credit_details = try cloneResetCreditDetailsView(allocator, snapshot.reset_credit_details), - .next_reset_at = nextResetAt(snapshot), + .reset_credit = try registry.cloneResetCreditCards(allocator, snapshot.reset_credit orelse &.{}), .refresh = refresh, }; } - -fn cloneResetCreditDetailsView( - allocator: std.mem.Allocator, - details: ?registry.RateLimitResetCredits, -) !?registry.RateLimitResetCredits { - const value = details orelse return null; - return try registry.cloneRateLimitResetCredits(allocator, value); -} - -fn nextResetAt(snapshot: registry.RateLimitSnapshot) ?i64 { - var next: ?i64 = null; - const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); - for ([_]?registry.RateLimitWindow{ snapshot.primary, snapshot.secondary }) |window_opt| { - const window = window_opt orelse continue; - const reset_at = window.resets_at orelse continue; - if (reset_at <= now) continue; - if (next == null or reset_at < next.?) next = reset_at; - } - return next; +fn cloneResetCreditCardsView(allocator: std.mem.Allocator, snapshot: ?registry.RateLimitSnapshot) !?[]registry.ResetCreditCard { + const snapshot_value = snapshot orelse return null; + const cards = snapshot_value.reset_credit orelse return null; + return try registry.cloneResetCreditCards(allocator, cards); } fn cloneCreditsView(allocator: std.mem.Allocator, credits: ?registry.CreditsSnapshot) !?CreditsView { diff --git a/src/workflows/usage.zig b/src/workflows/usage.zig index 9a28226a..ce3937d6 100644 --- a/src/workflows/usage.zig +++ b/src/workflows/usage.zig @@ -25,7 +25,7 @@ const ForegroundUsageWorkerResult = struct { missing_auth: bool = false, error_name: ?[]const u8 = null, snapshot: ?registry.RateLimitSnapshot = null, - auth_expires_at: ?i64 = null, + access_token_expires_at: ?i64 = null, pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { if (self.snapshot) |*snapshot| { @@ -410,7 +410,7 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable .missing_auth = batch_result.missing_auth, .error_name = batch_result.error_name, .snapshot = batch_result.snapshot, - .auth_expires_at = batch_result.auth_expires_at, + .access_token_expires_at = batch_result.access_token_expires_at, }; batch_result.snapshot = null; } @@ -450,9 +450,9 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable } const outcome = &state.outcomes[idx]; if (!worker_result.requested) continue; - if (worker_result.auth_expires_at) |expiry| { - if (reg.accounts.items[idx].auth_expires_at != expiry) { - reg.accounts.items[idx].auth_expires_at = expiry; + if (worker_result.access_token_expires_at) |expiry| { + if (reg.accounts.items[idx].access_token_expires_at != expiry) { + reg.accounts.items[idx].access_token_expires_at = expiry; registry_changed = true; } } @@ -673,7 +673,7 @@ fn foregroundUsageRefreshWorker( .status_code = fetch_result.status_code, .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, - .auth_expires_at = fetch_result.auth_expires_at, + .access_token_expires_at = fetch_result.access_token_expires_at, }; if (fetch_result.snapshot) |snapshot| { @@ -683,7 +683,7 @@ fn foregroundUsageRefreshWorker( .status_code = fetch_result.status_code, .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, - .auth_expires_at = fetch_result.auth_expires_at, + .access_token_expires_at = fetch_result.access_token_expires_at, .error_name = @errorName(err), }; return; diff --git a/tests/api_usage_test.zig b/tests/api_usage_test.zig index 5c6fc87c..b8e06da4 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -96,18 +96,16 @@ test "parse reset credit details keeps every returned field" { \\} ; - const details = (try usage_api.parseResetCreditResponse(gpa, body)) orelse return error.TestExpectedEqual; + const cards = (try usage_api.parseResetCreditResponse(gpa, body)) orelse return error.TestExpectedEqual; defer { - for (details.credits) |credit| registry.freeRateLimitResetCredit(gpa, credit); - gpa.free(details.credits); + for (cards) |card| registry.freeResetCreditCard(gpa, card); + gpa.free(cards); } - try std.testing.expectEqual(@as(i64, 2), details.available_count); - try std.testing.expectEqual(@as(?i64, 4), details.total_earned_count); - try std.testing.expectEqual(@as(usize, 2), details.credits.len); - try std.testing.expectEqualStrings("credit-1", details.credits[0].id); - try std.testing.expectEqualStrings("2026-07-17T00:00:00Z", details.credits[0].expires_at.?); - try std.testing.expect(details.credits[1].expires_at == null); + try std.testing.expectEqual(@as(usize, 2), cards.len); + try std.testing.expectEqualStrings("credit-1", cards[0].id); + try std.testing.expectEqualStrings("2026-07-17T00:00:00Z", cards[0].expires_at.?); + try std.testing.expect(cards[1].expires_at == null); } test "parse usage api response keeps numeric credits without windows" { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index ac1a3b71..17e92147 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1587,7 +1587,7 @@ test "Scenario: Given a usage snapshot plan when building switch rows then the d try std.testing.expectEqualStrings("Business", rows.items[0].plan); } -test "live list details show reset-card expiry columns and scroll within the viewport" { +test "live list details show reset credits cell and scroll within the viewport" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); defer reg.deinit(gpa); @@ -1597,13 +1597,14 @@ test "live list details show reset-card expiry columns and scroll within the vie \\{"id":"first-card","reset_type":"codex_rate_limits","status":"used","granted_at":"2026-08-01T00:00:00Z","expires_at":null,"title":"First card"}, \\{"id":"last-card","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-09-01T00:00:00Z","expires_at":"2026-10-01T00:00:00Z","title":"Last card","description":"Reset quota.\u001b[2J"}]} ); - reg.accounts.items[0].auth_expires_at = 1790000000; + reg.accounts.items[0].access_token_expires_at = 1790000000; reg.accounts.items[0].last_usage = .{ .primary = .{ .used_percent = 30, .window_minutes = 300, .resets_at = 1790000100 }, .secondary = null, .credits = null, .plan_type = .pro, - .reset_credit_details = details, + .reset_credits = 1, + .reset_credit = details, }; var rows = try buildSwitchRows(gpa, ®); defer rows.deinit(gpa); @@ -1611,9 +1612,10 @@ test "live list details show reset-card expiry columns and scroll within the vie defer body.deinit(); var styled_body = StyledWriter.init(&body.writer, false); try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, null); - for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "NEXT RESET LOCAL", "2026-10-01 00:00:00 UTC" }) |expected| { + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "ACCESS EXPIRE", "RESET CREDITS", "1 (exp 10-01)" }) |expected| { try std.testing.expect(std.mem.indexOf(u8, body.written(), expected) != null); } + try std.testing.expect(std.mem.indexOf(u8, body.written(), "00:00:00 UTC") == null); try std.testing.expect(std.mem.indexOfScalar(u8, body.written(), 27) == null); var frame: std.Io.Writer.Allocating = .init(gpa); defer frame.deinit(); @@ -1623,7 +1625,7 @@ test "live list details show reset-card expiry columns and scroll within the vie var viewport = live_tui.listViewport(7, count, live_tui.listFixedLines("ready"), &start); viewport.max_cols = null; try cli.render.renderListBodyViewport(&styled_frame, body.written(), "ready", viewport); - try std.testing.expect(std.mem.indexOf(u8, frame.written(), "2026-10-01") != null); + try std.testing.expect(std.mem.indexOf(u8, frame.written(), "10-01") != null); try std.testing.expect(std.mem.indexOf(u8, frame.written(), "ACCOUNT") != null); try std.testing.expect(std.mem.indexOf(u8, frame.written(), "ready") != null); try std.testing.expect(std.mem.count(u8, frame.written(), "\n") <= 7); @@ -1676,7 +1678,7 @@ test "live list distinguishes unavailable reset details from an empty server lis .secondary = null, .credits = null, .plan_type = .pro, - .reset_credit_details = try codex_auth.api.usage.parseResetCreditResponse(gpa, + .reset_credit = try codex_auth.api.usage.parseResetCreditResponse(gpa, \\{"available_count":0,"credits":[]} ), }; diff --git a/tests/registry_test.zig b/tests/registry_test.zig index afcad8d9..a2dcdef2 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -516,7 +516,7 @@ test "registry load normalizes schema four without previous active account key" defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"previous_active_account_key\": null") != null); } @@ -583,7 +583,7 @@ test "schema three registry plan values migrate to canonical product plans" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"team\"") == null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"business\"") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"plan\": \"enterprise\"") != null); @@ -825,7 +825,7 @@ test "legacy schema registry with legacy rollout attribution rewrites to normali defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"active_account_activated_at_ms\": 0") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"last_attributed_rollout\"") == null); } @@ -857,7 +857,7 @@ test "legacy current-layout registry version field rewrites to schema_version" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"interval_seconds\": 60") != null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"live\"") == null); try std.testing.expect(std.mem.indexOf(u8, contents, "\"version\"") == null); @@ -946,7 +946,7 @@ test "v2 registry migrates active email records to current schema" { defer file.close(); const contents = try file.readToEndAlloc(gpa, 10 * 1024 * 1024); defer gpa.free(contents); - try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 5") != null); + try std.testing.expect(std.mem.indexOf(u8, contents, "\"schema_version\": 4") != null); const active_expect = try std.fmt.allocPrint(gpa, "\"active_account_key\": \"{s}\"", .{expected_account_id}); defer gpa.free(active_expect); try std.testing.expect(std.mem.indexOf(u8, contents, active_expect) != null); diff --git a/tests/workflows_live_test.zig b/tests/workflows_live_test.zig index d3ea9c72..1d888c35 100644 --- a/tests/workflows_live_test.zig +++ b/tests/workflows_live_test.zig @@ -268,7 +268,7 @@ test "live refresh merge preserves accounts newly added to the latest registry" defer refreshed.deinit(gpa); try appendLiveMergeTestAccount(gpa, &refreshed, "user-alpha::acct-alpha", "alpha@example.com", "alpha"); refreshed.accounts.items[0].account_name = try gpa.dupe(u8, "Alpha Workspace"); - refreshed.accounts.items[0].auth_expires_at = 1790000000; + refreshed.accounts.items[0].access_token_expires_at = 1790000000; var latest: registry.Registry = .{ .schema_version = registry.current_schema_version, @@ -288,7 +288,7 @@ test "live refresh merge preserves accounts newly added to the latest registry" const alpha_idx = findAccountIndexByAccountKeyConst(&latest, "user-alpha::acct-alpha") orelse return error.TestExpectedEqual; const beta_idx = findAccountIndexByAccountKeyConst(&latest, "user-beta::acct-beta") orelse return error.TestExpectedEqual; try std.testing.expectEqualStrings("Alpha Workspace", latest.accounts.items[alpha_idx].account_name.?); - try std.testing.expectEqual(@as(?i64, 1790000000), latest.accounts.items[alpha_idx].auth_expires_at); + try std.testing.expectEqual(@as(?i64, 1790000000), latest.accounts.items[alpha_idx].access_token_expires_at); try std.testing.expect(latest.accounts.items[beta_idx].account_name == null); const usage_overrides = try gpa.alloc(?[]const u8, refreshed.accounts.items.len); From 4c8f446666913d3f56d8ee569262b6b6268571ab Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 16:13:47 +0800 Subject: [PATCH 4/7] fix: accept flat reset_credit arrays in registry parsing The registry stores the slimmed flat array; the wire object shape (from the details endpoint) is still accepted for the API path. Without this, loading a registry written by the slimmed build drops the card records on the next rewrite (lossy proxy). [skip ci] --- src/registry/parse.zig | 51 ++++++------------------------------------ 1 file changed, 7 insertions(+), 44 deletions(-) diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 7d745326..84db2ce2 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -71,13 +71,14 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_versio } pub fn parseResetCreditList(allocator: std.mem.Allocator, v: std.json.Value) !?[]ResetCreditCard { - const obj = switch (v) { - .object => |o| o, - else => return null, - }; - const credits_value = obj.get("credits") orelse return null; - const items = switch (credits_value) { + // Accepts both shapes: the persisted flat array (registry.json) and the + // backend wire object (`{"credits": [...]}`). + const items = switch (v) { .array => |array| array.items, + .object => |o| switch (o.get("credits") orelse return null) { + .array => |array| array.items, + else => return null, + }, else => return null, }; var cards = std.ArrayList(ResetCreditCard).empty; @@ -119,44 +120,6 @@ fn parseResetCreditCard(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ! }; } -fn parseResetCredit(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !?RateLimitResetCredit { - const id = try requiredStringAlloc(allocator, obj.get("id")) orelse return null; - errdefer allocator.free(id); - const reset_type = try requiredStringAlloc(allocator, obj.get("reset_type")) orelse { - allocator.free(id); - return null; - }; - errdefer allocator.free(reset_type); - const status = try requiredStringAlloc(allocator, obj.get("status")) orelse { - allocator.free(id); - allocator.free(reset_type); - return null; - }; - errdefer allocator.free(status); - const granted_at = try requiredStringAlloc(allocator, obj.get("granted_at")) orelse { - allocator.free(id); - allocator.free(reset_type); - allocator.free(status); - return null; - }; - errdefer allocator.free(granted_at); - const expires_at = try parseOptionalStoredStringAlloc(allocator, obj.get("expires_at")); - errdefer if (expires_at) |value| allocator.free(value); - const title = try parseOptionalStoredStringAlloc(allocator, obj.get("title")); - errdefer if (title) |value| allocator.free(value); - const description = try parseOptionalStoredStringAlloc(allocator, obj.get("description")); - errdefer if (description) |value| allocator.free(value); - return .{ - .id = id, - .reset_type = reset_type, - .status = status, - .granted_at = granted_at, - .expires_at = expires_at, - .title = title, - .description = description, - }; -} - fn optionalStringAlloc(allocator: std.mem.Allocator, value: ?std.json.Value) ?[]u8 { const text = switch (value orelse return null) { .string => |s| s, From 30ad7b071d4c0c9f1e5f896a4c93faf26fd55706 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 16:27:54 +0800 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20single=20parse=20shape=20per=20?= =?UTF-8?q?layer=20=E2=80=94=20registry=20parses=20flat=20arrays=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset-credit parser served two callers with two shapes (registry flat array, backend wire object). Move the wire-object unpacking to the API layer (parseResetCreditResponse extracts credits[] itself) so the registry parser only ever sees the persisted flat array — no dual-shape compat branch. [skip ci] --- src/api/usage.zig | 11 ++++++++++- src/registry/parse.zig | 12 ++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/api/usage.zig b/src/api/usage.zig index d62b6adb..e6ac92fe 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -353,7 +353,16 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi pub fn parseResetCreditResponse(allocator: std.mem.Allocator, body: []const u8) !?[]registry.ResetCreditCard { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .parse_numbers = false }); defer parsed.deinit(); - return try registry_parse.parseResetCreditList(allocator, parsed.value); + const obj = switch (parsed.value) { + .object => |o| o, + else => return null, + }; + const credits = obj.get("credits") orelse return null; + const items = switch (credits) { + .array => |array| array.items, + else => return null, + }; + return try registry_parse.parseResetCreditCards(allocator, items); } fn parseResetCredits(v: std.json.Value) ?i64 { diff --git a/src/registry/parse.zig b/src/registry/parse.zig index 84db2ce2..f6c6bb21 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -71,16 +71,16 @@ pub fn parseUsage(allocator: std.mem.Allocator, v: std.json.Value, schema_versio } pub fn parseResetCreditList(allocator: std.mem.Allocator, v: std.json.Value) !?[]ResetCreditCard { - // Accepts both shapes: the persisted flat array (registry.json) and the - // backend wire object (`{"credits": [...]}`). + // The persisted registry shape is a flat array of cards. const items = switch (v) { .array => |array| array.items, - .object => |o| switch (o.get("credits") orelse return null) { - .array => |array| array.items, - else => return null, - }, else => return null, }; + return try parseResetCreditCards(allocator, items); +} + +/// Parses an array of reset-credit card objects (from the backend wire format). +pub fn parseResetCreditCards(allocator: std.mem.Allocator, items: []std.json.Value) ![]ResetCreditCard { var cards = std.ArrayList(ResetCreditCard).empty; errdefer { for (cards.items) |card| common.freeResetCreditCard(allocator, card); From 7fa4c1ec120aa99ad799ac162e3cc129c0006f7f Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 16:41:14 +0800 Subject: [PATCH 6/7] fix: rename ACCESS EXPIRE -> AUTH EXPIRE; revert schema-migration.md edit - table header ACCESS EXPIRE -> AUTH EXPIRE (the expiry is the stored auth credential's, matching the field name) - docs/schema-migration.md: no changes in this PR (the schema stays at the documented v4 policy) [skip ci] --- docs/schema-migration.md | 2 +- src/cli/list_details.zig | 2 +- src/registry/parse.zig | 11 +++++++++++ tests/cli_picker_test.zig | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/schema-migration.md b/docs/schema-migration.md index 906e4eeb..dd91c0d2 100644 --- a/docs/schema-migration.md +++ b/docs/schema-migration.md @@ -20,7 +20,7 @@ This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/r - User-visible behavior is always “upgrade directly to the latest supported schema”. - Internally, migrations are implemented as a chain of `Vn -> Vn+1` steps. -- The guaranteed automatic migration is `schema_version = 3 -> schema_version = 4`; older supported layouts are rewritten directly to schema `4`. +- The guaranteed automatic migration is `schema_version = 3 -> schema_version = 4`. - The existing legacy `version = 2` loader also rewrites directly to schema `4`. - Users are not expected to install intermediate `codex-auth` versions. diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 8c18e841..24ecca51 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -4,7 +4,7 @@ const rows = @import("rows.zig"); const style = @import("style.zig"); const table_layout = @import("table_layout.zig"); -const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST", "ACCESS EXPIRE", "RESET CREDITS" }; +const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "LAST", "AUTH EXPIRE", "RESET CREDITS" }; const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 10, 16, 14 }; const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 5, 10, 8 }; const base_column_count = base_headers.len; diff --git a/src/registry/parse.zig b/src/registry/parse.zig index f6c6bb21..1a5c3d08 100644 --- a/src/registry/parse.zig +++ b/src/registry/parse.zig @@ -97,9 +97,20 @@ pub fn parseResetCreditCards(allocator: std.mem.Allocator, items: []std.json.Val return err; }; } + // Persist in ascending expires_at order — soonest first, cards without an + // expiry last (upstream's picker sorts the same way). Storage order is + // render order. + std.mem.sort(ResetCreditCard, cards.items, {}, cardByExpiryLessThan); return try cards.toOwnedSlice(allocator); } +fn cardByExpiryLessThan(_: void, a: ResetCreditCard, b: ResetCreditCard) bool { + // Backend ISO-8601 strings compare chronologically as bytes (uniform Z form). + if (a.expires_at == null) return false; + if (b.expires_at == null) return true; + return std.mem.order(u8, a.expires_at.?, b.expires_at.?) == .lt; +} + fn parseResetCreditCard(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !?ResetCreditCard { const id = try requiredStringAlloc(allocator, obj.get("id")) orelse return null; errdefer allocator.free(id); diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 17e92147..cccefb4b 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1612,7 +1612,7 @@ test "live list details show reset credits cell and scroll within the viewport" defer body.deinit(); var styled_body = StyledWriter.init(&body.writer, false); try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, null); - for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "ACCESS EXPIRE", "RESET CREDITS", "1 (exp 10-01)" }) |expected| { + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "test@e", "AUTH EXPIRE", "RESET CREDITS", "1 (exp 10-01)" }) |expected| { try std.testing.expect(std.mem.indexOf(u8, body.written(), expected) != null); } try std.testing.expect(std.mem.indexOf(u8, body.written(), "00:00:00 UTC") == null); From 9b03aaa8578a9d1a4fae7ca77cd29bda4a49667b Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 16:56:51 +0800 Subject: [PATCH 7/7] fix: derive the reset credits count from available cards when the summary is absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RESET CREDITS cell counts status==available cards from the persisted reset_credit list when rate_limit_reset_credits.available_count is absent from the usage snapshot — the same backend number, matching the upstream picker's Available filter. [skip ci] --- src/cli/list_details.zig | 19 ++++++++++++++++--- tests/cli_picker_test.zig | 27 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 24ecca51..559cef20 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -8,6 +8,7 @@ const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "5H", "WEEKLY", "LA const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 10, 16, 14 }; const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 5, 10, 8 }; const base_column_count = base_headers.len; +const available_status = "available"; const separator_width: usize = 3; pub fn writeHeader(writer: *style.StyledWriter, number_width: usize, max_cols: ?usize) !void { @@ -61,7 +62,19 @@ pub fn write( /// the available cards in local `MMM-DD` form (omitted when unknown). fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecord) ![]u8 { const usage = account.last_usage orelse return std.fmt.allocPrint(allocator, "-", .{}); - const count = usage.reset_credits orelse return std.fmt.allocPrint(allocator, "-", .{}); + // Count: the summary's available_count when present; otherwise derived + // from the persisted cards (status == available) — the same backend number. + var count: ?i64 = usage.reset_credits; + if (count == null) { + if (usage.reset_credit) |cards| { + var derived: i64 = 0; + for (cards) |card| { + if (std.mem.eql(u8, card.status, available_status)) derived += 1; + } + count = derived; + } + } + const value = count orelse return std.fmt.allocPrint(allocator, "-", .{}); var earliest: ?i64 = null; if (usage.reset_credit) |cards| { for (cards) |card| { @@ -74,9 +87,9 @@ fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecor if (earliest) |epoch| { const local = try rows.formatTimestampAlloc(allocator, epoch); defer allocator.free(local); - if (local.len >= 10) return std.fmt.allocPrint(allocator, "{d} (exp {s})", .{ count, local[5..10] }); + if (local.len >= 10) return std.fmt.allocPrint(allocator, "{d} (exp {s})", .{ value, local[5..10] }); } - return std.fmt.allocPrint(allocator, "{d}", .{count}); + return std.fmt.allocPrint(allocator, "{d}", .{value}); } fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count]bool { diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index cccefb4b..2aadb5d4 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1643,6 +1643,33 @@ test "live list details show reset credits cell and scroll within the viewport" try std.testing.expect(std.mem.indexOf(u8, color_body.written(), "\x1b[2m") != null); } +test "live list derives the reset credits count from available cards when the summary is absent" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + const details = try codex_auth.api.usage.parseResetCreditResponse(gpa, + \\{"available_count":1,"total_earned_count":2,"credits":[ + \\{"id":"first-card","reset_type":"codex_rate_limits","status":"used","granted_at":"2026-08-01T00:00:00Z","expires_at":null,"title":"First card"}, + \\{"id":"last-card","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-09-01T00:00:00Z","expires_at":"2026-10-01T00:00:00Z","title":"Last card"}]} + ); + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 30, .window_minutes = 300, .resets_at = 1790000100 }, + .secondary = null, + .credits = null, + .plan_type = .pro, + .reset_credits = null, + .reset_credit = details, + }; + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + var styled_body = StyledWriter.init(&body.writer, false); + try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "1 (exp 10-01)") != null); +} + test "live list keeps the active marker visible for double-digit accounts" { const gpa = std.testing.allocator; var reg = makeTestRegistry();