diff --git a/docs/commands/list.md b/docs/commands/list.md index 5fc7545d..4e0f7d47 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 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 + 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. - 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..130dd22b 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", + "access_token_expires_at": null, "active": true, "created_at": 1730000000, "last_used_at": 1730001000, @@ -64,6 +65,7 @@ shortcut and is rejected when combined with `--json`. "balance": null }, "reset_credits": null, + "reset_credit": null, "refresh": { "requested": true, "method": "api", @@ -75,10 +77,24 @@ shortcut and is rejected when combined with `--json`. } ``` +`reset_credits` remains the backward-compatible available-count field. +`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 +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`. +`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. + `plan` is already normalized by the CLI. Important mappings are: | Input observed by the CLI | JSON plan | diff --git a/src/api/usage.zig b/src/api/usage.zig index 3e3a7277..e6ac92fe 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, + access_token_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, + access_token_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.access_token_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].access_token_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 = 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; + } + } 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.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; + 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,21 @@ pub fn parseUsageResponse(allocator: std.mem.Allocator, body: []const u8) !?regi return snapshot; } +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(); + 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 { 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..0a5bd6ae 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("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"); @@ -191,11 +193,37 @@ 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"); + try writeResetCreditList(jw, usage.reset_credit); try jw.objectField("refresh"); try writeUsageRefresh(jw, &usage.refresh); try jw.endObject(); } +fn writeResetCreditList( + jw: *std.json.Stringify, + details: ?[]registry.ResetCreditCard, +) !void { + const value = details orelse { + try jw.write(null); + return; + }; + try jw.beginArray(); + for (value) |card| { + try jw.beginObject(); + try jw.objectField("id"); + try jw.write(card.id); + try jw.objectField("status"); + try jw.write(card.status); + try jw.objectField("expires_at"); + try jw.write(card.expires_at); + try jw.objectField("title"); + try jw.write(card.title); + try jw.endObject(); + } + try jw.endArray(); +} + 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..559cef20 --- /dev/null +++ b/src/cli/list_details.zig @@ -0,0 +1,227 @@ +const std = @import("std"); +const registry = @import("../registry/root.zig"); +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", "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; +const available_status = "available"; +const separator_width: usize = 3; + +pub fn writeHeader(writer: *style.StyledWriter, number_width: usize, max_cols: ?usize) !void { + const visible_base = visibleBaseColumns(max_cols, number_width); + 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; + 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; + } + 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, + writer: *style.StyledWriter, + account: registry.AccountRecord, + row: anytype, + number_width: usize, + max_cols: ?usize, +) !void { + const visible_base = visibleBaseColumns(max_cols, number_width); + 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: [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, row.last, token_expiry, reset_credits_cell }; + 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; + } + 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); +} + +/// `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, "-", .{}); + // 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| { + if (!std.mem.eql(u8, card.status, "available")) continue; + if (parseIsoEpoch(card.expires_at orelse "")) |epoch| { + if (earliest == null or epoch < earliest.?) earliest = epoch; + } + } + } + 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})", .{ value, local[5..10] }); + } + return std.fmt.allocPrint(allocator, "{d}", .{value}); +} + +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, 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 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[index]) continue; + total += if (index == 0) @max(width, number_width) else width; + column_count += 1; + } + return if (column_count == 0) 0 else total + (column_count - 1) * separator_width; +} + +fn writeAlignedRow( + 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: [base_column_count]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; + } + } + try writer.writeStyle(row_style); + for (values, 0..) |value, index| { + 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(writer.out, value, widths[index]); + } else { + try table_layout.writeTruncatedPadded(writer.out, value, widths[index]); + } + } + try writer.reset(); + try writer.writeAll("\n"); +} + +fn minimumForWidths(widths: []const usize) usize { + var total: usize = 0; + for (widths) |width| total += width; + return total + (widths.len - 1) * separator_width; +} + +/// 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 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; + } + } + } + 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/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..64800dc2 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,94 @@ 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 number_width = idx_width + 1; + try details.writeHeader(writer, 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, 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_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..cbe9c3b2 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].access_token_expires_at != info.access_token_expires_at) { + changed = true; + } + 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); @@ -603,6 +605,7 @@ pub fn accountFromAuth( .account_name = null, .plan = info.plan, .auth_mode = info.auth_mode, + .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, @@ -640,6 +643,7 @@ pub fn accountFromApiKeyMe( .account_name = owned_account_name, .plan = null, .auth_mode = .apikey, + .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 3a97f9d2..2f6b34dd 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -64,11 +64,19 @@ pub const CreditsSnapshot = struct { balance: ?[]u8, }; +pub const ResetCreditCard = struct { + id: []u8, + status: []u8, + expires_at: ?[]u8, + title: ?[]u8, +}; + pub const RateLimitSnapshot = struct { primary: ?RateLimitWindow, secondary: ?RateLimitWindow, credits: ?CreditsSnapshot, reset_credits: ?i64 = null, + reset_credit: ?[]ResetCreditCard = null, plan_type: ?PlanType, }; @@ -99,6 +107,7 @@ pub const AccountRecord = struct { account_name: ?[]u8, plan: ?PlanType, auth_mode: ?AuthMode, + access_token_expires_at: ?i64 = null, created_at: i64, last_used_at: ?i64, last_usage: ?RateLimitSnapshot, @@ -177,6 +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) |cards| { + for (cards) |card| freeResetCreditCard(allocator, card); + allocator.free(cards); + } +} + +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 { @@ -213,15 +233,38 @@ pub fn cloneRateLimitSnapshot(allocator: std.mem.Allocator, snapshot: RateLimitS if (credits.balance) |balance| allocator.free(balance); }; + var cloned_reset_credit: ?[]ResetCreditCard = null; + if (snapshot.reset_credit) |cards| { + cloned_reset_credit = try cloneResetCreditCards(allocator, cards); + } + return .{ .primary = snapshot.primary, .secondary = snapshot.secondary, .credits = cloned_credits, .reset_credits = snapshot.reset_credits, + .reset_credit = cloned_reset_credit, .plan_type = snapshot.plan_type, }; } +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[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 cloned; +} + pub fn setRolloutSignature( allocator: std.mem.Allocator, target: *?RolloutSignature, @@ -264,9 +307,29 @@ 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 + resetCreditEqual(a.reset_credit, b.reset_credit) and a.plan_type == b.plan_type; } +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.?.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; +} + 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..697f4acd 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].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 f3a66557..1a5c3d08 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 ResetCreditCard = common.ResetCreditCard; 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 = null, .plan_type = null }; if (obj.get("plan_type")) |p| { switch (p) { @@ -62,9 +64,100 @@ 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| { + snap.reset_credit = parseResetCreditList(allocator, details) catch null; + } return snap; } +pub fn parseResetCreditList(allocator: std.mem.Allocator, v: std.json.Value) !?[]ResetCreditCard { + // The persisted registry shape is a flat array of cards. + const items = switch (v) { + .array => |array| array.items, + 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); + cards.deinit(allocator); + } + 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; + }; + } + // 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); + 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 .{ + .id = id, + .status = status, + .expires_at = expires_at, + .title = title, + }; +} + +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, + 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 +248,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..2a18040d 100644 --- a/src/registry/root.zig +++ b/src/registry/root.zig @@ -23,6 +23,7 @@ pub const realPathAlloc = common.realPathAlloc; pub const readFileAlloc = common.readFileAlloc; pub const RateLimitWindow = common.RateLimitWindow; pub const CreditsSnapshot = common.CreditsSnapshot; +pub const ResetCreditCard = common.ResetCreditCard; pub const RateLimitSnapshot = common.RateLimitSnapshot; pub const RolloutSignature = common.RolloutSignature; pub const ApiConfig = common.ApiConfig; @@ -41,14 +42,17 @@ pub const defaultApiConfig = common.defaultApiConfig; pub const defaultLiveConfig = common.defaultLiveConfig; pub const freeAccountRecord = common.freeAccountRecord; pub const freeRateLimitSnapshot = common.freeRateLimitSnapshot; +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 cloneResetCreditCards = common.cloneResetCreditCards; 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..201e7ccc 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, + .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, @@ -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..edfeca25 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, + .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 d9dfefad..f5520f8b 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.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; @@ -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.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); @@ -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, + .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 ed767cd4..f09cdf85 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,15 @@ pub const UsageView = struct { secondary: ?registry.RateLimitWindow = null, credits: ?CreditsView = null, reset_credits: ?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) |cards| { + for (cards) |card| registry.freeResetCreditCard(allocator, card); + allocator.free(cards); + } self.refresh.deinit(allocator); self.* = undefined; } @@ -72,6 +78,7 @@ pub const AccountView = struct { account_name: ?[]u8, plan: ?registry.PlanType, auth_mode: ?registry.AuthMode, + access_token_expires_at: ?i64, active: bool, created_at: i64, last_used_at: ?i64, @@ -277,6 +284,7 @@ fn buildAccountView( .account_name = account_name, .plan = registry.resolveDisplayPlan(rec), .auth_mode = rec.auth_mode, + .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, @@ -296,7 +304,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,9 +314,15 @@ fn buildUsageView( .secondary = snapshot.secondary, .credits = credits, .reset_credits = snapshot.reset_credits, + .reset_credit = try registry.cloneResetCreditCards(allocator, snapshot.reset_credit orelse &.{}), .refresh = refresh, }; } +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 { const value = credits orelse return null; diff --git a/src/workflows/usage.zig b/src/workflows/usage.zig index 5caf8208..ce3937d6 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, + access_token_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, + .access_token_expires_at = batch_result.access_token_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.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; + } + } 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, + .access_token_expires_at = fetch_result.access_token_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, + .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 a79329bc..b8e06da4 100644 --- a/tests/api_usage_test.zig +++ b/tests/api_usage_test.zig @@ -67,6 +67,47 @@ 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 cards = (try usage_api.parseResetCreditResponse(gpa, body)) orelse return error.TestExpectedEqual; + defer { + for (cards) |card| registry.freeResetCreditCard(gpa, card); + gpa.free(cards); + } + + 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" { 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..2aadb5d4 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1586,3 +1586,131 @@ 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 credits cell 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].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_credits = 1, + .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); + 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); + 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 = null; + try cli.render.renderListBodyViewport(&styled_frame, body.written(), "ready", viewport); + 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); + + 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); + + 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 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(); + 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 = 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/workflows_live_test.zig b/tests/workflows_live_test.zig index c5f38383..1d888c35 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].access_token_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].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);