From 50b54c44e599f0a445b47b06058c041e518cd792 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 18:24:31 +0800 Subject: [PATCH 1/7] feat: show plan expiry in list output - Parse chatgpt_subscription_active_until from the stored id_token. - Persist plan_expires_at and expose it in list JSON. - Add PLAN EXPIRE to plain and live list tables; missing or past timestamps render as '-'. - Refresh plan expiry from auth and usage sync; keep schema version 4 because the field is optional. - Cover ISO-8601/numeric claim parsing and expired display in tests. Closes #157 --- build.zig | 1 + docs/commands/list.md | 25 +++++--- docs/implement.md | 6 +- docs/json-api.md | 14 +++-- docs/table-layout.md | 34 ++++++---- src/api/usage.zig | 40 ++++++++++-- src/auth/auth.zig | 25 ++++++++ src/cli/json_output.zig | 2 + src/cli/list_details.zig | 57 ++++------------- src/cli/rows.zig | 14 +++++ src/cli/table_layout.zig | 19 +++++- src/registry/account_ops.zig | 7 +++ src/registry/common.zig | 1 + src/registry/import.zig | 1 + src/registry/storage.zig | 1 + src/registry/storage_parse.zig | 1 + src/root.zig | 1 + src/time/timestamp.zig | 110 +++++++++++++++++++++++++++++++++ src/tui/table.zig | 19 ++++-- src/workflows/live_display.zig | 4 ++ src/workflows/results.zig | 2 + src/workflows/usage.zig | 14 +++++ tests/auth_test.zig | 44 +++++++++++++ tests/cli_picker_test.zig | 27 ++++++++ tests/registry_test.zig | 3 + tests/time_timestamp_test.zig | 47 ++++++++++++++ tests/tui_table_test.zig | 56 +++++++++++++++++ tests/workflows_core_test.zig | 32 ++++++++++ 28 files changed, 525 insertions(+), 82 deletions(-) create mode 100644 src/time/timestamp.zig create mode 100644 tests/time_timestamp_test.zig diff --git a/build.zig b/build.zig index 49730dc7..58e8a222 100644 --- a/build.zig +++ b/build.zig @@ -89,6 +89,7 @@ pub fn build(b: *std.Build) void { "tests/registry_test.zig", "tests/session_test.zig", "tests/time_relative_test.zig", + "tests/time_timestamp_test.zig", "tests/table_layout_test.zig", "tests/tui_display_test.zig", "tests/tui_session_test.zig", diff --git a/docs/commands/list.md b/docs/commands/list.md index 4e0f7d47..17af1be4 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -18,6 +18,7 @@ codex-auth list --json - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. - Non-live output shows `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, and `LAST ACTIVITY`. + When the terminal is wide enough, it also shows `PLAN EXPIRE`. ## Refresh Modes @@ -27,15 +28,17 @@ 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 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. +- Live account details show the access-token expiry, the plan expiry, and, per + account, the available reset-credit count with the earliest card expiry. The + `AUTH EXPIRE`, `PLAN 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. + expiry; refreshing credentials can extend it. `PLAN EXPIRE` comes from the + `chatgpt_subscription_active_until` claim in the stored `id_token`. Missing or + already-expired values render as `-`, so a stale snapshot does not look current. + 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. @@ -47,8 +50,12 @@ When local-only refresh is active, only the active account can be updated from l - Grouped rows keep the shared email in the header; child rows with both alias and account name render as `alias(account name)`. - Usage cells show remaining percent and reset time when that data is known. - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. +- `PLAN EXPIRE` renders the local date from the stored `id_token` subscription claim. + Missing or past timestamps render as `-` in both plain and live tables. - `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. +- JSON output includes the raw `plan_expires_at` timestamp and the returned reset-credit details. + The terminal column hides past plan-expiry timestamps, but the JSON value remains the + stored timestamp so callers can detect stale snapshots. - 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/implement.md b/docs/implement.md index 00566462..a43d7ce1 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -76,6 +76,8 @@ If `OPENAI_API_KEY` is present, the account is treated as API-key auth. Otherwis - `tokens.id_token` - a ChatGPT account context from `tokens.account_id`, JWT `https://api.openai.com/auth.chatgpt_account_id`, or JWT `https://api.openai.com/auth.organizations[].id` - JWT user identity from `chatgpt_user_id` or `user_id` +- Optional plan expiry from JWT `https://api.openai.com/auth.chatgpt_subscription_active_until` + (ISO-8601 string or epoch seconds/milliseconds) If required identity fields are missing or mismatched, import/login fails. Existing-registry foreground sync skips unsyncable auth files and continues with registry state already on disk. @@ -86,7 +88,7 @@ Foreground account commands sync `auth.json` before their main work when the cur The sync flow is: 1. Read `~/.codex/auth.json`. -2. Parse email, plan, auth mode, `chatgpt_user_id`, and `chatgpt_account_id`. +2. Parse email, plan, plan expiry, auth mode, `chatgpt_user_id`, and `chatgpt_account_id`. 3. Match by `record_key`. 4. Update the matching account and active key, or create a new account record when no match exists. 5. Rewrite the managed account snapshot when contents changed. @@ -124,6 +126,8 @@ When API usage refresh is disabled, local usage refresh reads Codex rollout file - Alias labels take precedence for child rows. - Duplicate workspace-style plan labels may use stable numbered labels. - `PLAN` comes from the auth claim when available, then falls back to usage snapshot plan data. +- `PLAN EXPIRE` comes from the stored `id_token` subscription claim. Missing or already-expired + timestamps render as `-`; the column is omitted on narrow terminals. - Usage cells show remaining percentage plus reset time when reset data is known. - `LAST ACTIVITY` is rendered as relative time from `last_usage_at`. diff --git a/docs/json-api.md b/docs/json-api.md index 130dd22b..29606dc8 100644 --- a/docs/json-api.md +++ b/docs/json-api.md @@ -47,6 +47,7 @@ shortcut and is rejected when combined with `--json`. "plan": "business", "auth_mode": "chatgpt", "access_token_expires_at": null, + "plan_expires_at": null, "active": true, "created_at": 1730000000, "last_used_at": 1730001000, @@ -83,9 +84,13 @@ 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. +`plan_expires_at` is the subscription-period end parsed from the stored +`id_token` claim `https://api.openai.com/auth.chatgpt_subscription_active_until` +and normalized to Unix seconds. It is `null` when the claim is absent, for +example for API-key or free-plan accounts. The value is a snapshot: it changes +only when the stored auth token is refreshed. The `PLAN EXPIRE` terminal column +hides timestamps that are not in the future and renders `-`; JSON keeps the raw +timestamp so callers can detect stale data. `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 @@ -93,7 +98,8 @@ 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. +entitlement expiration timestamp. Use `plan_expires_at` for the subscription +period end. `plan` is already normalized by the CLI. Important mappings are: diff --git a/docs/table-layout.md b/docs/table-layout.md index b03806eb..cc31677c 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -3,12 +3,14 @@ Every table view uses the same responsive allocator in `src/cli/table_layout.zig`. The full column model is: -`ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY` +`ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, +and a deferred `PLAN EXPIRE`. -The plain `list` table enables all seven columns. The live views -(`switch`, `remove`, and `list --live`) disable `CREDITS` and -`RESET CREDITS`: those columns are absent from the layout, and their width and -separator budget is redistributed to the remaining columns. +The plain `list` table enables all eight columns. The live `switch` and +`remove` views disable `CREDITS`, `RESET CREDITS`, and the deferred +`PLAN EXPIRE`: those columns are absent from the layout, and their width and +separator budget is redistributed to the remaining columns. `list --live` +uses its own account-detail table. ## Column Allocation @@ -27,6 +29,11 @@ ACCOUNT (10) -> LAST (4) ``` +`PLAN EXPIRE` is deferred: it is not part of the first pass or the grow pass. +It only receives leftover width after every other visible column has had a +chance to grow. On mid-sized terminals this keeps usage and status values from +being squeezed by the extra column. + - Separators are charged only for columns that are actually visible. - A non-account column is shown only when its target width and separator fit. - `ACCOUNT` may use a partial width down to 2 columns when the terminal is @@ -92,17 +99,20 @@ label is truncated. ## Examples -Plain `list`, where all seven columns are enabled: +Plain `list`, where all eight columns are enabled on a wide terminal: ```text -ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST --------------------------------------------------------- -very-long. Pro - - 100% 58% - -work-main. Pro unlimited 3 31% 42% - +ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST PLAN EXPIRE +------------------------------------------------------------------ +very-long. Pro - - 100% 58% - 2026-11-22 +work-main. Pro unlimited 3 31% 42% - 2026-12-01 ``` -Live view, where `CREDITS` and `RESET CREDITS` are disabled and their space is -redistributed: +At 70 columns, the deferred `PLAN EXPIRE` column is omitted so `FileNotFound` +and other status cells stay readable. + +Live view, where `CREDITS`, `RESET CREDITS`, and `PLAN EXPIRE` are disabled and +their space is redistributed: ```text ACCOUNT PLAN 5H WEEKLY diff --git a/src/api/usage.zig b/src/api/usage.zig index e6ac92fe..690ace1d 100644 --- a/src/api/usage.zig +++ b/src/api/usage.zig @@ -13,6 +13,8 @@ pub const UsageFetchResult = struct { error_code: ?ResponseErrorCode = null, missing_auth: bool = false, access_token_expires_at: ?i64 = null, + plan_expires_at: ?i64 = null, + plan_expires_at_known: bool = false, }; pub const max_response_error_code_bytes: usize = 64; @@ -33,6 +35,8 @@ pub const BatchUsageFetchResult = struct { missing_auth: bool = false, error_name: ?[]const u8 = null, access_token_expires_at: ?i64 = null, + plan_expires_at: ?i64 = null, + plan_expires_at_known: bool = false, pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { if (self.snapshot) |*snapshot| { @@ -72,14 +76,40 @@ pub fn fetchUsageForAuthPath(allocator: std.mem.Allocator, auth_path: []const u8 pub fn fetchUsageForAuthPathDetailed(allocator: std.mem.Allocator, auth_path: []const u8) !UsageFetchResult { const info = try auth.parseAuthInfo(allocator, auth_path); defer info.deinit(allocator); + const plan_expires_at = info.plan_expires_at; - if (info.auth_mode == .apikey) return .{ .snapshot = null, .status_code = null }; - if (info.auth_mode != .chatgpt) return .{ .snapshot = null, .status_code = null, .missing_auth = true }; - 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 }; + if (info.auth_mode == .apikey) return .{ + .snapshot = null, + .status_code = null, + .plan_expires_at = plan_expires_at, + .plan_expires_at_known = true, + }; + if (info.auth_mode != .chatgpt) return .{ + .snapshot = null, + .status_code = null, + .missing_auth = true, + .plan_expires_at = plan_expires_at, + .plan_expires_at_known = true, + }; + const access_token = info.access_token orelse return .{ + .snapshot = null, + .status_code = null, + .missing_auth = true, + .plan_expires_at = plan_expires_at, + .plan_expires_at_known = true, + }; + const chatgpt_account_id = info.chatgpt_account_id orelse return .{ + .snapshot = null, + .status_code = null, + .missing_auth = true, + .plan_expires_at = plan_expires_at, + .plan_expires_at_known = true, + }; 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; + result.plan_expires_at = plan_expires_at; + result.plan_expires_at_known = true; return result; } @@ -111,6 +141,8 @@ pub fn fetchUsageForAuthPathsDetailedBatch( }; defer info.deinit(arena); results[idx].access_token_expires_at = info.access_token_expires_at; + results[idx].plan_expires_at = info.plan_expires_at; + results[idx].plan_expires_at_known = true; if (info.auth_mode == .apikey) { continue; diff --git a/src/auth/auth.zig b/src/auth/auth.zig index ab21e956..f35feca6 100644 --- a/src/auth/auth.zig +++ b/src/auth/auth.zig @@ -1,6 +1,7 @@ const std = @import("std"); const app_runtime = @import("../core/runtime.zig"); const registry = @import("../registry/root.zig"); +const timestamp = @import("../time/timestamp.zig"); pub const AuthInfo = struct { email: ?[]u8, @@ -11,6 +12,7 @@ pub const AuthInfo = struct { openai_api_key: ?[]u8 = null, last_refresh: ?[]u8, access_token_expires_at: ?i64 = null, + plan_expires_at: ?i64 = null, plan: ?registry.PlanType, auth_mode: registry.AuthMode, @@ -92,6 +94,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .openai_api_key = try allocator.dupe(u8, trimmed), .last_refresh = null, .access_token_expires_at = null, + .plan_expires_at = null, .plan = null, .auth_mode = .apikey, }; @@ -146,6 +149,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn } var plan: ?registry.PlanType = null; + var plan_expires_at: ?i64 = null; if (cobj.get("https://api.openai.com/auth")) |auth_obj| { switch (auth_obj) { .object => |aobj| { @@ -168,6 +172,9 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn else => {}, } } + if (aobj.get("chatgpt_subscription_active_until")) |until| { + plan_expires_at = parseClaimTimestamp(until); + } if (aobj.get("chatgpt_user_id")) |uid| { switch (uid) { .string => |s| { @@ -209,6 +216,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .openai_api_key = null, .last_refresh = last_refresh, .access_token_expires_at = access_token_expires_at, + .plan_expires_at = plan_expires_at, .plan = plan, .auth_mode = .chatgpt, }; @@ -246,6 +254,7 @@ pub fn parseAuthInfoData(allocator: std.mem.Allocator, data: []const u8) !AuthIn .openai_api_key = null, .last_refresh = null, .access_token_expires_at = null, + .plan_expires_at = null, .plan = null, .auth_mode = .chatgpt, }; @@ -359,6 +368,22 @@ fn parsePlanType(s: []const u8) registry.PlanType { return registry.normalizePlanType(s); } +fn parseClaimTimestamp(value: std.json.Value) ?i64 { + return switch (value) { + .string, .number_string => |text| parseClaimTimestampText(text), + .integer => |number| timestamp.epochSeconds(number), + else => null, + }; +} + +fn parseClaimTimestampText(text: []const u8) ?i64 { + const trimmed = std.mem.trim(u8, text, &std.ascii.whitespace); + if (trimmed.len == 0) return null; + if (timestamp.parseIso8601(trimmed)) |seconds| return seconds; + const numeric = std.fmt.parseInt(i64, trimmed, 10) catch return null; + return timestamp.epochSeconds(numeric); +} + fn organizationAccountIdAlloc(allocator: std.mem.Allocator, auth_obj: std.json.ObjectMap) !?[]u8 { const organizations_val = auth_obj.get("organizations") orelse return null; const organizations = switch (organizations_val) { diff --git a/src/cli/json_output.zig b/src/cli/json_output.zig index 0a5bd6ae..48fbf8df 100644 --- a/src/cli/json_output.zig +++ b/src/cli/json_output.zig @@ -168,6 +168,8 @@ fn writeAccount(jw: *std.json.Stringify, account: *const results.AccountView) !v 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("plan_expires_at"); + try jw.write(account.plan_expires_at); try jw.objectField("active"); try jw.write(account.active); try jw.objectField("created_at"); diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 559cef20..5e923a2e 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -1,12 +1,14 @@ const std = @import("std"); +const app_runtime = @import("../core/runtime.zig"); const registry = @import("../registry/root.zig"); const rows = @import("rows.zig"); const style = @import("style.zig"); const table_layout = @import("table_layout.zig"); +const timestamp = @import("../time/timestamp.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_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "5H", "WEEKLY", "LAST", "AUTH EXPIRE", "RESET CREDITS" }; +const base_desired_widths = [_]usize{ 2, 18, 8, 11, 12, 12, 10, 16, 14 }; +const base_minimum_widths = [_]usize{ 1, 8, 4, 8, 4, 6, 5, 10, 8 }; const base_column_count = base_headers.len; const available_status = "available"; const separator_width: usize = 3; @@ -36,8 +38,11 @@ pub fn write( max_cols: ?usize, ) !void { const visible_base = visibleBaseColumns(max_cols, number_width); + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); const token_expiry = try rows.formatTimestampAlloc(allocator, account.access_token_expires_at); defer allocator.free(token_expiry); + const plan_expiry = try rows.formatFutureDateAlloc(allocator, account.plan_expires_at, now); + defer allocator.free(plan_expiry); const reset_credits_cell = try resetCreditsCell(allocator, account); defer allocator.free(reset_credits_cell); @@ -46,7 +51,7 @@ pub fn write( 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 }; + const base_values = [_][]const u8{ number, row.account, row.plan, plan_expiry, 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; @@ -79,7 +84,7 @@ fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecor 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 (timestamp.parseIso8601(card.expires_at orelse "")) |epoch| { if (earliest == null or epoch < earliest.?) earliest = epoch; } } @@ -96,7 +101,7 @@ fn visibleBaseColumns(max_cols: ?usize, number_width: usize) [base_column_count] 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 }; + const drop_order = [_]usize{ 8, 7, 6, 3, 5, 2 }; while (minimumTableWidth(visible, number_width) > limit) { var changed = false; for (drop_order) |index| { @@ -185,43 +190,3 @@ fn minimumForWidths(widths: []const usize) usize { 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/rows.zig b/src/cli/rows.zig index 642a0314..dd9442e8 100644 --- a/src/cli/rows.zig +++ b/src/cli/rows.zig @@ -368,3 +368,17 @@ pub fn formatTimestampAlloc(allocator: std.mem.Allocator, timestamp: ?i64) ![]u8 year, month, day, hour, minute, }); } + +/// Formats a future timestamp as a local `YYYY-MM-DD` date. Missing, invalid, +/// and already-expired timestamps render as `-` so stale snapshots do not look +/// like current entitlements. +pub fn formatFutureDateAlloc(allocator: std.mem.Allocator, timestamp: ?i64, now: i64) ![]u8 { + const value = timestamp orelse return allocator.dupe(u8, "-"); + if (value <= now) return allocator.dupe(u8, "-"); + var tm: c.struct_tm = undefined; + if (!localtimeCompat(value, &tm)) return allocator.dupe(u8, "-"); + const year: u32 = @intCast(tm.tm_year + 1900); + const month: u32 = @intCast(tm.tm_mon + 1); + const day: u32 = @intCast(tm.tm_mday); + return std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}", .{ year, month, day }); +} diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 70b54740..01467e94 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -12,7 +12,7 @@ pub const LiveListViewport = struct { }; pub const live_column_count = 5; -pub const column_count = 7; +pub const column_count = 8; const live_account_ident_width: usize = 10; const account_min_width: usize = 2; const live_account_suffix_min_width: usize = 10; @@ -116,6 +116,7 @@ pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols const first_pass_order = [_]usize{ 0, 4, 5, 2, 1, 3, 6 }; const grow_pass_order = [_]usize{ 2, 4, 5, 1, 3, 6, 0 }; +const deferred_column_index: usize = 7; const column_target_widths = [_]usize{ live_account_ident_width, // ACCOUNT "PLAN".len, // PLAN @@ -124,6 +125,7 @@ const column_target_widths = [_]usize{ @max(4, "5H".len), // 5H "WEEKLY".len, // WEEKLY 4, // LAST + 10, // PLAN EXPIRE }; /// Shared responsive column allocator for every table view. @@ -173,6 +175,20 @@ pub fn boundColumnWidths(widths: [column_count]usize, prefix_len: usize, max_col growBoundedWidth(&remaining, &bounded[index], widths[index]); } + // Deferred pass: admit a low-priority column only after every existing + // column has had a chance to grow. This keeps the plan-expiry column from + // stealing width from usage and status cells on mid-sized terminals. + if (bounded[deferred_column_index] == 0 and widths[deferred_column_index] > 0) { + const separator_width: usize = if (visible_count == 0) 0 else 2; + if (remaining > separator_width) { + const width = @min(widths[deferred_column_index], remaining - separator_width); + if (width > 0) { + bounded[deferred_column_index] = width; + remaining -= width + separator_width; + } + } + } + return bounded; } @@ -185,6 +201,7 @@ pub fn switchWidthsToColumns(widths: SwitchWidths) [column_count]usize { widths.rate_5h, widths.rate_week, widths.last, + 0, }; } diff --git a/src/registry/account_ops.zig b/src/registry/account_ops.zig index cbe9c3b2..023297f7 100644 --- a/src/registry/account_ops.zig +++ b/src/registry/account_ops.zig @@ -196,6 +196,10 @@ pub fn syncActiveAccountFromAuthWithImporter(allocator: std.mem.Allocator, codex changed = true; } reg.accounts.items[idx].access_token_expires_at = info.access_token_expires_at; + if (reg.accounts.items[idx].plan_expires_at != info.plan_expires_at) { + changed = true; + } + reg.accounts.items[idx].plan_expires_at = info.plan_expires_at; const dest = try accountAuthPath(allocator, codex_home, rec_account_key); defer allocator.free(dest); @@ -606,6 +610,7 @@ pub fn accountFromAuth( .plan = info.plan, .auth_mode = info.auth_mode, .access_token_expires_at = info.access_token_expires_at, + .plan_expires_at = info.plan_expires_at, .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = null, .last_usage = null, @@ -644,6 +649,7 @@ pub fn accountFromApiKeyMe( .plan = null, .auth_mode = .apikey, .access_token_expires_at = null, + .plan_expires_at = null, .created_at = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(), .last_used_at = null, .last_usage = null, @@ -683,6 +689,7 @@ pub fn mergeAccountRecord(allocator: std.mem.Allocator, dest: *AccountRecord, in } if (dest.plan == null) dest.plan = merged_incoming.plan; if (dest.auth_mode == null) dest.auth_mode = merged_incoming.auth_mode; + if (dest.plan_expires_at == null) dest.plan_expires_at = merged_incoming.plan_expires_at; freeAccountRecord(allocator, &merged_incoming); } diff --git a/src/registry/common.zig b/src/registry/common.zig index 2f6b34dd..00427790 100644 --- a/src/registry/common.zig +++ b/src/registry/common.zig @@ -108,6 +108,7 @@ pub const AccountRecord = struct { plan: ?PlanType, auth_mode: ?AuthMode, access_token_expires_at: ?i64 = null, + plan_expires_at: ?i64 = null, created_at: i64, last_used_at: ?i64, last_usage: ?RateLimitSnapshot, diff --git a/src/registry/import.zig b/src/registry/import.zig index 697f4acd..b7e5d2d8 100644 --- a/src/registry/import.zig +++ b/src/registry/import.zig @@ -611,6 +611,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; + reg.accounts.items[idx].plan_expires_at = info.plan_expires_at; } else { var record = try accountFromAuth(allocator, "", &info); errdefer freeAccountRecord(allocator, &record); diff --git a/src/registry/storage.zig b/src/registry/storage.zig index 201e7ccc..9ae5dff6 100644 --- a/src/registry/storage.zig +++ b/src/registry/storage.zig @@ -210,6 +210,7 @@ fn migrateLegacyRecord( .plan = info.plan orelse legacy.plan, .auth_mode = info.auth_mode, .access_token_expires_at = info.access_token_expires_at, + .plan_expires_at = info.plan_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 edfeca25..30d5a5fe 100644 --- a/src/registry/storage_parse.zig +++ b/src/registry/storage_parse.zig @@ -44,6 +44,7 @@ pub fn parseAccountRecord(allocator: std.mem.Allocator, obj: std.json.ObjectMap, .plan = null, .auth_mode = null, .access_token_expires_at = readInt(obj.get("access_token_expires_at")), + .plan_expires_at = readInt(obj.get("plan_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/root.zig b/src/root.zig index cf9adf4c..ab15e0aa 100644 --- a/src/root.zig +++ b/src/root.zig @@ -28,6 +28,7 @@ pub const terminal = struct { pub const time = struct { pub const relative = @import("time/relative.zig"); + pub const timestamp = @import("time/timestamp.zig"); }; pub const tui = struct { diff --git a/src/time/timestamp.zig b/src/time/timestamp.zig new file mode 100644 index 00000000..45464e81 --- /dev/null +++ b/src/time/timestamp.zig @@ -0,0 +1,110 @@ +const std = @import("std"); + +/// Parses an RFC 3339 / ISO-8601 timestamp into Unix seconds. +/// +/// Accepts an optional fractional second and a `Z`, `±HH`, `±HHMM`, or +/// `±HH:MM` offset. A missing offset is treated as UTC. +pub fn parseIso8601(value: []const u8) ?i64 { + const text = std.mem.trim(u8, value, &std.ascii.whitespace); + if (text.len < 19 or text[4] != '-' or text[7] != '-' or + (text[10] != 'T' and text[10] != 't') or text[13] != ':' or text[16] != ':') + { + return null; + } + + const year = std.fmt.parseInt(i64, text[0..4], 10) catch return null; + const month = std.fmt.parseInt(i64, text[5..7], 10) catch return null; + const day = std.fmt.parseInt(i64, text[8..10], 10) catch return null; + const hour = std.fmt.parseInt(i64, text[11..13], 10) catch return null; + const minute = std.fmt.parseInt(i64, text[14..16], 10) catch return null; + const second = std.fmt.parseInt(i64, text[17..19], 10) catch return null; + if (month < 1 or month > 12 or day < 1 or day > daysInMonth(year, month) or + hour < 0 or hour > 23 or minute < 0 or minute > 59 or second < 0 or second > 59) + { + return null; + } + + var index: usize = 19; + if (index < text.len and text[index] == '.') { + index += 1; + const fraction_start = index; + while (index < text.len and std.ascii.isDigit(text[index])) : (index += 1) {} + if (index == fraction_start) return null; + } + + var offset_seconds: i64 = 0; + if (index < text.len) { + switch (text[index]) { + 'Z', 'z' => index += 1, + '+', '-' => { + const sign: i64 = if (text[index] == '+') -1 else 1; + index += 1; + const offset_hour_start = index; + var offset_hour_digits: usize = 0; + while (offset_hour_digits < 2 and index < text.len and std.ascii.isDigit(text[index])) { + index += 1; + offset_hour_digits += 1; + } + if (offset_hour_digits != 2) return null; + const offset_hour = std.fmt.parseInt(i64, text[offset_hour_start..index], 10) catch return null; + if (offset_hour > 23) return null; + + var offset_minute: i64 = 0; + if (index < text.len) { + if (text[index] == ':') index += 1; + const offset_minute_start = index; + var offset_minute_digits: usize = 0; + while (offset_minute_digits < 2 and index < text.len and std.ascii.isDigit(text[index])) { + index += 1; + offset_minute_digits += 1; + } + if (offset_minute_digits != 2) return null; + offset_minute = std.fmt.parseInt(i64, text[offset_minute_start..index], 10) catch return null; + if (offset_minute > 59) return null; + } + offset_seconds = sign * (offset_hour * 3600 + offset_minute * 60); + }, + else => return null, + } + } + if (index != text.len) return null; + + return daysFromCivil(year, month, day) * 86400 + + hour * 3600 + minute * 60 + second + + offset_seconds; +} + +/// Converts an epoch value expressed in seconds, milliseconds, or microseconds +/// to seconds. +pub fn epochSeconds(value: i64) i64 { + var result = value; + while (result >= 1_000_000_000_000 or result <= -1_000_000_000_000) { + result = @divTrunc(result, 1000); + } + return result; +} + +fn daysInMonth(year: i64, month: i64) i64 { + return switch (month) { + 1, 3, 5, 7, 8, 10, 12 => 31, + 4, 6, 9, 11 => 30, + 2 => if (isLeapYear(year)) 29 else 28, + else => 0, + }; +} + +fn isLeapYear(year: i64) bool { + return @mod(year, 4) == 0 and (@mod(year, 100) != 0 or @mod(year, 400) == 0); +} + +/// 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/tui/table.zig b/src/tui/table.zig index 684e5b61..edc15773 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -3,6 +3,7 @@ const app_runtime = @import("../core/runtime.zig"); const builtin = @import("builtin"); const display_rows = @import("display.zig"); const table_layout = @import("../cli/table_layout.zig"); +const cli_rows = @import("../cli/rows.zig"); const registry = @import("../registry/root.zig"); const io_util = @import("../core/io_util.zig"); const rate_limit = @import("rate_limit.zig"); @@ -136,8 +137,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( usage_overrides: ?[]const ?[]const u8, term_cols: ?usize, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "LAST ACTIVITY" }; - const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "CR", "RESET", "5H", "WEEKLY", "LAST" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "LAST ACTIVITY", "PLAN EXPIRE" }; + const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "CR", "RESET", "5H", "WEEKLY", "LAST", "PLAN EXP" }; var content_widths = [_]usize{ headers[0].len, headers[1].len, @@ -146,6 +147,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( headers[4].len, headers[5].len, headers[6].len, + headers[7].len, }; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, null); @@ -173,6 +175,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(rate_week_str); const last_str = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last_str); + const plan_expiry_str = try cli_rows.formatFutureDateAlloc(std.heap.page_allocator, rec.plan_expires_at, now); + defer std.heap.page_allocator.free(plan_expiry_str); content_widths[1] = @max(content_widths[1], plan.len); content_widths[2] = @max(content_widths[2], credits_str.len); @@ -180,12 +184,13 @@ pub fn writeAccountsTableWithUsageOverridesForCols( content_widths[4] = @max(content_widths[4], rate_5h_str.len); content_widths[5] = @max(content_widths[5], rate_week_str.len); content_widths[6] = @max(content_widths[6], last_str.len); + content_widths[7] = @max(content_widths[7], plan_expiry_str.len); } } const widths = table_layout.boundColumnWidths(content_widths, prefix_len, term_cols); - var visible: [7]bool = undefined; - var header_cells: [7][]const u8 = undefined; + var visible: [8]bool = undefined; + var header_cells: [8][]const u8 = undefined; for (widths, 0..) |width, index| { visible[index] = width > 0; const full = headers[index]; @@ -243,6 +248,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(rate_week_str); const last = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last); + const plan_expiry = try cli_rows.formatFutureDateAlloc(std.heap.page_allocator, rec.plan_expires_at, now); + defer std.heap.page_allocator.free(plan_expiry); const indent: usize = @as(usize, row.depth) * 2; const indent_to_print: usize = @min(indent, widths[0]); const account_cell = try truncateAlloc(row.account_cell, widths[0] - indent_to_print); @@ -259,6 +266,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(rate_week_cell); const last_cell = try truncateAlloc(last, widths[6]); defer std.heap.page_allocator.free(last_cell); + const plan_expiry_cell = try truncateAlloc(plan_expiry, widths[7]); + defer std.heap.page_allocator.free(plan_expiry_cell); if (use_color) { if (row.is_active) { try out.writeAll(ansi.green); @@ -270,7 +279,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( try writeIndexPadded(out, selectable_counter + 1, idx_width); try out.writeAll(" "); try writeRepeat(out, ' ', indent_to_print); - const row_cells = [_][]const u8{ account_cell, plan_cell, credits_cell, reset_credits_cell, rate_5h_cell, rate_week_cell, last_cell }; + const row_cells = [_][]const u8{ account_cell, plan_cell, credits_cell, reset_credits_cell, rate_5h_cell, rate_week_cell, last_cell, plan_expiry_cell }; var row_first_visible = true; for (row_cells, 0..) |cell, index| { if (!visible[index]) continue; diff --git a/src/workflows/live_display.zig b/src/workflows/live_display.zig index f5520f8b..75cb583b 100644 --- a/src/workflows/live_display.zig +++ b/src/workflows/live_display.zig @@ -38,6 +38,8 @@ pub fn switchLiveUsageFieldsEqual( ) 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; + if ((if (maybe_a) |rec| rec.plan_expires_at else null) != + (if (maybe_b) |rec| rec.plan_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; @@ -85,6 +87,7 @@ pub fn applySwitchLiveUsageDeltaToLatest( if (!switchLiveUsageFieldsEqual(base_rec, latest_rec)) return false; latest_rec.access_token_expires_at = refreshed_rec.access_token_expires_at; + latest_rec.plan_expires_at = refreshed_rec.plan_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); @@ -215,6 +218,7 @@ pub fn cloneAccountRecord(allocator: std.mem.Allocator, rec: *const registry.Acc .plan = rec.plan, .auth_mode = rec.auth_mode, .access_token_expires_at = rec.access_token_expires_at, + .plan_expires_at = rec.plan_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 f09cdf85..1cc93df8 100644 --- a/src/workflows/results.zig +++ b/src/workflows/results.zig @@ -79,6 +79,7 @@ pub const AccountView = struct { plan: ?registry.PlanType, auth_mode: ?registry.AuthMode, access_token_expires_at: ?i64, + plan_expires_at: ?i64, active: bool, created_at: i64, last_used_at: ?i64, @@ -285,6 +286,7 @@ fn buildAccountView( .plan = registry.resolveDisplayPlan(rec), .auth_mode = rec.auth_mode, .access_token_expires_at = rec.access_token_expires_at, + .plan_expires_at = rec.plan_expires_at, .active = isActive(reg, account_idx), .created_at = rec.created_at, .last_used_at = rec.last_used_at, diff --git a/src/workflows/usage.zig b/src/workflows/usage.zig index 8bed5ca1..7fca0d62 100644 --- a/src/workflows/usage.zig +++ b/src/workflows/usage.zig @@ -26,6 +26,8 @@ const ForegroundUsageWorkerResult = struct { error_name: ?[]const u8 = null, snapshot: ?registry.RateLimitSnapshot = null, access_token_expires_at: ?i64 = null, + plan_expires_at: ?i64 = null, + plan_expires_at_known: bool = false, pub fn deinit(self: *@This(), allocator: std.mem.Allocator) void { if (self.snapshot) |*snapshot| { @@ -411,6 +413,8 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable .error_name = batch_result.error_name, .snapshot = batch_result.snapshot, .access_token_expires_at = batch_result.access_token_expires_at, + .plan_expires_at = batch_result.plan_expires_at, + .plan_expires_at_known = batch_result.plan_expires_at_known, }; batch_result.snapshot = null; } @@ -456,6 +460,12 @@ pub fn refreshForegroundUsageForDisplayWithApiFetchersWithPoolInitUsingApiEnable registry_changed = true; } } + if (worker_result.plan_expires_at_known) { + if (reg.accounts.items[idx].plan_expires_at != worker_result.plan_expires_at) { + reg.accounts.items[idx].plan_expires_at = worker_result.plan_expires_at; + registry_changed = true; + } + } outcome.* = .{ .attempted = true, .method = .api, @@ -674,6 +684,8 @@ fn foregroundUsageRefreshWorker( .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, .access_token_expires_at = fetch_result.access_token_expires_at, + .plan_expires_at = fetch_result.plan_expires_at, + .plan_expires_at_known = fetch_result.plan_expires_at_known, }; if (fetch_result.snapshot) |snapshot| { @@ -684,6 +696,8 @@ fn foregroundUsageRefreshWorker( .error_code = fetch_result.error_code, .missing_auth = fetch_result.missing_auth, .access_token_expires_at = fetch_result.access_token_expires_at, + .plan_expires_at = fetch_result.plan_expires_at, + .plan_expires_at_known = fetch_result.plan_expires_at_known, .error_name = @errorName(err), }; return; diff --git a/tests/auth_test.zig b/tests/auth_test.zig index 44e06437..f25fb5fc 100644 --- a/tests/auth_test.zig +++ b/tests/auth_test.zig @@ -400,3 +400,47 @@ test "convert standard auth json to cpa derives account id from id token" { try std.testing.expect(std.mem.indexOf(u8, converted, expected_json) != null); try std.testing.expect(std.mem.indexOf(u8, converted, "stale-account-id") == null); } + +fn authJsonWithSubscriptionUntil(allocator: std.mem.Allocator, claim_literal: []const u8) ![]u8 { + const header = "{\"alg\":\"none\",\"typ\":\"JWT\"}"; + const payload = try std.fmt.allocPrint( + allocator, + "{{\"email\":\"user@example.com\",\"https://api.openai.com/auth\":{{\"chatgpt_account_id\":\"acc\",\"chatgpt_user_id\":\"user\",\"user_id\":\"user\",\"chatgpt_plan_type\":\"plus\",\"chatgpt_subscription_active_until\":{s}}}}}", + .{claim_literal}, + ); + defer allocator.free(payload); + + const h64 = try b64url(allocator, header); + defer allocator.free(h64); + const p64 = try b64url(allocator, payload); + defer allocator.free(p64); + const jwt = try std.mem.concat(allocator, u8, &[_][]const u8{ h64, ".", p64, ".sig" }); + defer allocator.free(jwt); + + return try std.fmt.allocPrint( + allocator, + "{{\"tokens\":{{\"access_token\":\"access\",\"account_id\":\"acc\",\"id_token\":\"{s}\"}}}}", + .{jwt}, + ); +} + +test "parse auth info reads plan expiry from the id token subscription claim" { + const gpa = std.testing.allocator; + const cases = [_]struct { + claim: []const u8, + expected: ?i64, + }{ + .{ .claim = "\"2026-07-29T12:09:00Z\"", .expected = 1785326940 }, + .{ .claim = "\"2026-08-25T09:36:13.284171+00:00\"", .expected = 1787650573 }, + .{ .claim = "1785326940000", .expected = 1785326940 }, + .{ .claim = "null", .expected = null }, + }; + + for (cases) |case| { + const json = try authJsonWithSubscriptionUntil(gpa, case.claim); + defer gpa.free(json); + const info = try auth.parseAuthInfoData(gpa, json); + defer info.deinit(gpa); + try std.testing.expectEqual(case.expected, info.plan_expires_at); + } +} diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index b6234050..fa137722 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1704,6 +1704,33 @@ test "live list derives the reset credits count from available cards when the su try std.testing.expect(std.mem.indexOf(u8, body.written(), "1 (exp 10-01)") != null); } +test "live list shows plan expiry only when it is still in the future" { + const gpa = std.testing.allocator; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + + reg.accounts.items[0].plan_expires_at = now + 30 * 86400; + const expected = try cli.rows.formatFutureDateAlloc(gpa, reg.accounts.items[0].plan_expires_at, now); + defer gpa.free(expected); + + 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(), "PLAN EXPIRE") != null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), expected) != null); + + reg.accounts.items[0].plan_expires_at = now - 30 * 86400; + body.clearRetainingCapacity(); + try cli.render.renderListBody(gpa, &styled_body, ®, rows.items, 2, rows.widths, null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "PLAN EXPIRE") != null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), expected) == null); +} + test "live list keeps the active marker visible for double-digit accounts" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); diff --git a/tests/registry_test.zig b/tests/registry_test.zig index a2dcdef2..7c775011 100644 --- a/tests/registry_test.zig +++ b/tests/registry_test.zig @@ -288,6 +288,7 @@ test "registry save/load" { defer gpa.free(active_account_key); try registry.setActiveAccountKey(gpa, ®, active_account_key); reg.api.usage = true; + reg.accounts.items[0].plan_expires_at = 1_785_326_940; try registry.setAccountLastLocalRollout(gpa, ®.accounts.items[0], "/tmp/sessions/run-1/rollout-a.jsonl", 1735689600000); try registry.saveRegistry(gpa, codex_home, ®); @@ -298,6 +299,7 @@ test "registry save/load" { defer gpa.free(saved); try std.testing.expect(std.mem.indexOf(u8, saved, "\"api\"") == null); try std.testing.expect(std.mem.indexOf(u8, saved, "\"previous_active_account_key\": null") != null); + try std.testing.expect(std.mem.indexOf(u8, saved, "\"plan_expires_at\": 1785326940") != null); var loaded = try registry.loadRegistry(gpa, codex_home); defer loaded.deinit(gpa); @@ -310,6 +312,7 @@ test "registry save/load" { try std.testing.expectEqual(@as(i64, 1735689600000), loaded.accounts.items[0].last_local_rollout.?.event_timestamp_ms); try std.testing.expect(std.mem.eql(u8, loaded.accounts.items[0].last_local_rollout.?.path, "/tmp/sessions/run-1/rollout-a.jsonl")); try std.testing.expect(loaded.accounts.items[0].account_name == null); + try std.testing.expectEqual(@as(?i64, 1_785_326_940), loaded.accounts.items[0].plan_expires_at); } test "registry save/load round-trips previous active account key" { diff --git a/tests/time_timestamp_test.zig b/tests/time_timestamp_test.zig new file mode 100644 index 00000000..7ddf5fe6 --- /dev/null +++ b/tests/time_timestamp_test.zig @@ -0,0 +1,47 @@ +const std = @import("std"); +const timestamp = @import("codex_auth").time.timestamp; + +test "parseIso8601 accepts UTC, numeric offsets, and fractional seconds" { + try std.testing.expectEqual( + @as(?i64, 1776920618), + timestamp.parseIso8601("2026-04-23T05:03:38+00:00"), + ); + try std.testing.expectEqual( + @as(?i64, 1791277871), + timestamp.parseIso8601("2026-10-06T18:11:11+09:00"), + ); + try std.testing.expectEqual( + @as(?i64, 1787650573), + timestamp.parseIso8601("2026-08-25T09:36:13.284171+00:00"), + ); + try std.testing.expectEqual( + @as(?i64, 1785326940), + timestamp.parseIso8601("2026-07-29T12:09:00Z"), + ); + try std.testing.expectEqual( + @as(?i64, 1785326940), + timestamp.parseIso8601("2026-07-29T12:09:00"), + ); + try std.testing.expectEqual( + @as(?i64, 1785294540), + timestamp.parseIso8601("2026-07-29T12:09:00+0900"), + ); + try std.testing.expectEqual( + @as(?i64, 1785294540), + timestamp.parseIso8601("2026-07-29T12:09:00+09"), + ); +} + +test "parseIso8601 rejects malformed or impossible timestamps" { + try std.testing.expectEqual(@as(?i64, null), timestamp.parseIso8601("not-a-date")); + try std.testing.expectEqual(@as(?i64, null), timestamp.parseIso8601("2026-02-30T00:00:00Z")); + try std.testing.expectEqual(@as(?i64, null), timestamp.parseIso8601("2026-07-29T12:09:00+090")); + try std.testing.expectEqual(@as(?i64, null), timestamp.parseIso8601("2026-07-29T12:09:00Zjunk")); +} + +test "epochSeconds normalizes seconds, milliseconds, and microseconds" { + try std.testing.expectEqual(@as(i64, 1_785_326_940), timestamp.epochSeconds(1_785_326_940)); + try std.testing.expectEqual(@as(i64, 1_785_326_940), timestamp.epochSeconds(1_785_326_940_000)); + try std.testing.expectEqual(@as(i64, 1_785_326_940), timestamp.epochSeconds(1_785_326_940_000_000)); + try std.testing.expectEqual(@as(i64, -1_785_326_940), timestamp.epochSeconds(-1_785_326_940_000)); +} diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 1d53a44b..881fdc46 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -362,3 +362,59 @@ test "writeAccountsTableForCols: very narrow terminal hides low-priority columns } try std.testing.expect(std.mem.indexOf(u8, output, "100%") != null); } + +test "formatFutureDateAlloc renders only future dates" { + const allocator = std.testing.allocator; + const now: i64 = 1_800_000_000; + + const missing = try codex_auth.cli.rows.formatFutureDateAlloc(allocator, null, now); + defer allocator.free(missing); + try std.testing.expectEqualStrings("-", missing); + + const expired = try codex_auth.cli.rows.formatFutureDateAlloc(allocator, now - 1, now); + defer allocator.free(expired); + try std.testing.expectEqualStrings("-", expired); + + const boundary = try codex_auth.cli.rows.formatFutureDateAlloc(allocator, now, now); + defer allocator.free(boundary); + try std.testing.expectEqualStrings("-", boundary); + + const future = try codex_auth.cli.rows.formatFutureDateAlloc(allocator, now + 86400, now); + defer allocator.free(future); + try std.testing.expectEqual(@as(usize, 10), future.len); + try std.testing.expectEqual(@as(u8, '-'), future[4]); + try std.testing.expectEqual(@as(u8, '-'), future[7]); +} + +test "writeAccountsTable shows plan expiry for future data and hides expired data" { + const gpa = std.testing.allocator; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .plus); + + reg.accounts.items[0].plan_expires_at = now + 30 * 86400; + const expected = try codex_auth.cli.rows.formatFutureDateAlloc(gpa, reg.accounts.items[0].plan_expires_at, now); + defer gpa.free(expected); + + var buffer: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 200); + var output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "PLAN EXPIRE") != null); + try std.testing.expect(std.mem.indexOf(u8, output, expected) != null); + + const expired_at = now - 30 * 86400; + const expired_text = try codex_auth.cli.rows.formatTimestampAlloc(gpa, expired_at); + defer gpa.free(expired_text); + reg.accounts.items[0].plan_expires_at = expired_at; + + buffer = undefined; + writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 200); + output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "PLAN EXPIRE") != null); + if (expired_text.len >= 10) { + try std.testing.expect(std.mem.indexOf(u8, output, expired_text[0..10]) == null); + } +} diff --git a/tests/workflows_core_test.zig b/tests/workflows_core_test.zig index a1ed0763..0fac04d0 100644 --- a/tests/workflows_core_test.zig +++ b/tests/workflows_core_test.zig @@ -4,6 +4,7 @@ const app_runtime = @import("codex_auth").core.runtime; const fs = @import("codex_auth").core.compat_fs; const account_api = @import("codex_auth").api.account; const auth_mod = @import("codex_auth").auth.core; +const cli = @import("codex_auth").cli; const display_rows = @import("codex_auth").tui.display; const app_workflow = @import("codex_auth").app_workflow; const main_mod = @import("codex_auth").workflows; @@ -1545,3 +1546,34 @@ test "Scenario: Given no remaining accounts when reconciling after remove then a defer gpa.free(active_auth_path); try std.testing.expectError(error.FileNotFound, fs.cwd().openFile(active_auth_path, .{})); } + +test "list JSON includes the stored plan expiry timestamp" { + const gpa = std.testing.allocator; + var reg = makeRegistry(); + defer reg.deinit(gpa); + try appendAccount(gpa, ®, primary_record_key, "user@example.com", "", .pro); + reg.accounts.items[0].plan_expires_at = 1_785_326_940; + + const overrides = try gpa.alloc(?[]const u8, 1); + const outcomes = try gpa.alloc(main_mod.ForegroundUsageOutcome, 1); + overrides[0] = null; + outcomes[0] = .{}; + var state = main_mod.ForegroundUsageRefreshState{ + .usage_overrides = overrides, + .outcomes = outcomes, + }; + defer state.deinit(gpa); + + var result = try main_mod.results.buildListResult(gpa, ®, &state); + defer result.deinit(gpa); + + var buffer: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try cli.json_output.writeListResult(&writer, &result); + + var parsed = try std.json.parseFromSlice(std.json.Value, gpa, writer.buffered(), .{}); + defer parsed.deinit(); + const accounts = parsed.value.object.get("accounts").?.array.items; + try std.testing.expectEqual(@as(usize, 1), accounts.len); + try std.testing.expectEqual(@as(i64, 1_785_326_940), accounts[0].object.get("plan_expires_at").?.integer); +} From 1bc1285ca289179d8bcc136efab8cc5b3a88d155 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 18:50:59 +0800 Subject: [PATCH 2/7] refactor(table): add plan expiry through shared dynamic columns Use the shared column allocator for list, live details, switch, and remove. PLAN EXPIRE is a deferred column in the allocator instead of a hardcoded special case, so it only appears after normal columns have grown. Keep each view's visual order, preserve complete percentages, and document the dynamic column model in docs/dynamic-columns.md. Add width-sweep coverage for deferred plan expiry and live column order. --- docs/commands/list.md | 4 +- docs/dynamic-columns.md | 104 ++++++++++++++++ docs/table-layout.md | 93 +++----------- src/cli/list_details.zig | 253 ++++++++++++++++++-------------------- src/cli/render.zig | 6 +- src/cli/table_layout.zig | 228 ++++++++++++++++++++++------------ src/tui/table.zig | 18 ++- tests/cli_picker_test.zig | 39 ++++++ tests/tui_table_test.zig | 35 ++++++ 9 files changed, 481 insertions(+), 299 deletions(-) create mode 100644 docs/dynamic-columns.md diff --git a/docs/commands/list.md b/docs/commands/list.md index 17af1be4..88e83e0e 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -17,8 +17,8 @@ codex-auth list --json - Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable. - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. -- Non-live output shows `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, and `LAST ACTIVITY`. - When the terminal is wide enough, it also shows `PLAN EXPIRE`. +- Non-live output shows `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, and `PLAN EXPIRE`. + The deferred `PLAN EXPIRE` column appears only when the terminal is wide enough. ## Refresh Modes diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md new file mode 100644 index 00000000..651b3fff --- /dev/null +++ b/docs/dynamic-columns.md @@ -0,0 +1,104 @@ +# Dynamic Column Layout + +All table views use one responsive allocator in `src/cli/table_layout.zig`. +The allocator separates a column's semantic identity from its position on +screen, so each view can keep its own visual order while sharing the same width +policy. + +## Model + +Each view supplies: + +- `columns`: the visual order and content width of its supported columns; +- `first_order`: columns that reserve a useful target width first; +- `grow_order`: visible columns that may grow toward their content width; +- `deferred_order`: low-priority columns admitted only after every normal + column has had a chance to grow; +- `prefix_width`: width of the row prefix (`#`, checkbox, index); +- `separator_width`: width of the separator between visible columns. + +A column with content width `0` is unsupported or hidden by that view. Hidden +columns are skipped completely, including their header, cells, and separators. +Their width and separator budget is redistributed to the remaining columns. + +## Visual Order + +The order of each view is fixed and independent of priority: + +| View | Visual order | +|---|---| +| `list` | `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, `PLAN EXPIRE` | +| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `5H`, `WEEKLY`, `LAST`, `AUTH EXPIRE`, `RESET CREDITS` | +| `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | + +`switch` and `remove` hide `CREDITS`, `RESET CREDITS`, `AUTH EXPIRE`, and +`PLAN EXPIRE`. The plain `list` hides `AUTH EXPIRE`. + +## Allocation Phases + +1. **Reserve targets** in `first_order`. A column is admitted only when its + target width and separator fit. `ACCOUNT` may use a partial width down to + 2 columns; hidden columns are not emitted as a missing separator or a + one-column dot. +2. **Grow** visible columns in `grow_order` toward their content width. +3. **Admit deferred columns** in `deferred_order`, in order, only when their + target width and separator still fit after every normal column has grown. + +Separators are charged only when the next column becomes visible. A line never +exceeds the requested terminal width. + +## Per-View Priority + +### Plain `list` + +```text +reserve: ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> CREDITS (2) + -> PLAN (4) -> RESET CREDITS (2) -> LAST (4) +grow: CREDITS -> 5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> ACCOUNT +deferred: PLAN EXPIRE (10) +``` + +### `list --live` + +```text +reserve: #* -> ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) + -> LAST (4) -> AUTH EXPIRE (10) -> RESET CREDITS (2) +grow: AUTH EXPIRE -> RESET CREDITS -> PLAN -> LAST -> 5H -> WEEKLY -> ACCOUNT +deferred: PLAN EXPIRE (10) +``` + +### `switch`, `remove` + +```text +reserve: ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) -> LAST (4) +grow: 5H -> WEEKLY -> PLAN -> LAST -> ACCOUNT +deferred: none +``` + +## Headers + +Wide headers use their full label. Narrow columns use compact labels: + +| Column | Compact labels | +|---|---| +| `CREDITS` | `CR` | +| `RESET CREDITS` | `RESET`, then `RC` | +| `AUTH EXPIRE` | `AUTH EXP`, then `AUTH` | +| `PLAN EXPIRE` | `PLAN EXP`, then `PLAN` | + +## Adding a Column + +1. Add a `ColumnId` and its default target width. +2. Add it to the visual order of each view that supports it. Unsupported views + pass content width `0`. +3. Put it in `first_order`, `grow_order`, or `deferred_order` according to its + importance. A new low-priority metadata column normally belongs in + `deferred_order`, not in a hardcoded special case in the allocator. +4. Add width-aware cell formatting and a compact header when needed. +5. Add a width-sweep test for hidden/visible behavior, line length, and any + value that must stay complete (for example `100%`). + +## Related + +- Account-label truncation: [table-layout.md](table-layout.md) +- `list` column contents: [commands/list.md](commands/list.md) diff --git a/docs/table-layout.md b/docs/table-layout.md index cc31677c..a023ec65 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -1,75 +1,8 @@ # Table Layout -Every table view uses the same responsive allocator in -`src/cli/table_layout.zig`. The full column model is: - -`ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, -and a deferred `PLAN EXPIRE`. - -The plain `list` table enables all eight columns. The live `switch` and -`remove` views disable `CREDITS`, `RESET CREDITS`, and the deferred -`PLAN EXPIRE`: those columns are absent from the layout, and their width and -separator budget is redistributed to the remaining columns. `list --live` -uses its own account-detail table. - -## Column Allocation - -The allocator receives the content width of each column and the available -terminal width. A column with content width `0` is treated as disabled. - -### First pass: reserve useful targets by priority - -```text -ACCOUNT (10) - -> 5H (4) - -> WEEKLY (6) - -> CREDITS (2) - -> PLAN (4) - -> RESET CREDITS (2) - -> LAST (4) -``` - -`PLAN EXPIRE` is deferred: it is not part of the first pass or the grow pass. -It only receives leftover width after every other visible column has had a -chance to grow. On mid-sized terminals this keeps usage and status values from -being squeezed by the extra column. - -- Separators are charged only for columns that are actually visible. -- A non-account column is shown only when its target width and separator fit. -- `ACCOUNT` may use a partial width down to 2 columns when the terminal is - very narrow. -- A column that cannot receive a useful width is hidden instead of being - rendered as a one-column `.` cell. - -### Second pass: grow toward content width - -Visible columns grow in this order: - -```text -CREDITS -> 5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> ACCOUNT -``` - -The credit cell is grown first so values such as `unlimited` do not collapse to -`u.`. Usage and error-name cells grow before `LAST`, and `ACCOUNT` grows last so -a long account label cannot starve the values beside it. In the live views, -`CREDITS` and `RESET CREDITS` are disabled, so the effective order is: - -```text -5H -> WEEKLY -> PLAN -> LAST -> ACCOUNT -``` - -Columns with width `0` are skipped entirely, including their headers, cells, -and separators. - -## Headers - -Wide headers use their full label. When a column is narrower than its full -label, compact labels are used: - -| Column | Compact | -|---|---| -| `CREDITS` | `CR` | -| `RESET CREDITS` | `RESET`, then `RC` | +The responsive column policy is documented in +[dynamic-columns.md](dynamic-columns.md). This page covers the account cell and +a few rendered examples. ## Account Truncation @@ -99,7 +32,7 @@ label is truncated. ## Examples -Plain `list`, where all eight columns are enabled on a wide terminal: +Plain `list` at a wide width: ```text ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST PLAN EXPIRE @@ -108,14 +41,18 @@ very-long. Pro - - 100% 58% - 2026-11-22 work-main. Pro unlimited 3 31% 42% - 2026-12-01 ``` -At 70 columns, the deferred `PLAN EXPIRE` column is omitted so `FileNotFound` -and other status cells stay readable. +Plain `list` at 70 columns hides the deferred `PLAN EXPIRE` column so status +and usage values stay readable: + +```text +ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST +-------------------------------------------------------- +very-long. Pro - - 100% 58% - +``` -Live view, where `CREDITS`, `RESET CREDITS`, and `PLAN EXPIRE` are disabled and -their space is redistributed: +`list --live` at a medium width: ```text -ACCOUNT PLAN 5H WEEKLY ------------------------------------ -very-l..com Business 100% 42% +#* | ACCOUNT | PLAN | 5H | WEEKLY + 1 | very-l.om) | Pro | 100% | 58% ``` diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 5e923a2e..9fac4566 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -1,32 +1,60 @@ const std = @import("std"); -const app_runtime = @import("../core/runtime.zig"); const registry = @import("../registry/root.zig"); const rows = @import("rows.zig"); const style = @import("style.zig"); const table_layout = @import("table_layout.zig"); +const text_width = @import("../tui/text_width.zig"); const timestamp = @import("../time/timestamp.zig"); +const app_runtime = @import("../core/runtime.zig"); -const base_headers = [_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "5H", "WEEKLY", "LAST", "AUTH EXPIRE", "RESET CREDITS" }; -const base_desired_widths = [_]usize{ 2, 18, 8, 11, 12, 12, 10, 16, 14 }; -const base_minimum_widths = [_]usize{ 1, 8, 4, 8, 4, 6, 5, 10, 8 }; -const base_column_count = base_headers.len; const available_status = "available"; const separator_width: usize = 3; +const column_count = table_layout.list_detail_columns.len; -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; +pub fn computeWidths( + number_width: usize, + widths: rows.SwitchWidths, + max_cols: ?usize, +) [column_count]usize { + const columns = [_]table_layout.ColumnSpec{ + .{ + .id = .number, + .content_width = @max(number_width, "#*".len), + .target_width = @max(number_width, "#*".len), + .min_width = @max(number_width, "#*".len), + }, + table_layout.columnSpec(.account, @max(widths.email, "ACCOUNT".len)), + table_layout.columnSpec(.plan, @max(widths.plan, "PLAN".len)), + table_layout.columnSpec(.plan_expire, @max("PLAN EXPIRE".len, 10)), + table_layout.columnSpec(.rate_5h, @max(widths.rate_5h, "5H".len)), + table_layout.columnSpec(.rate_week, @max(widths.rate_week, "WEEKLY".len)), + table_layout.columnSpec(.last, @max(widths.last, "LAST".len)), + table_layout.columnSpec(.auth_expire, @max("AUTH EXPIRE".len, 16)), + table_layout.columnSpec(.reset_credits, @max("RESET CREDITS".len, 14)), + }; + var bounded = [_]usize{0} ** column_count; + table_layout.allocateColumns(.{ + .columns = &columns, + .first_order = &table_layout.list_detail_first_order, + .grow_order = &table_layout.list_detail_grow_order, + .deferred_order = &table_layout.list_detail_deferred_order, + .prefix_width = 0, + .separator_width = separator_width, + }, max_cols, &bounded); + return bounded; +} + +pub fn writeHeader(writer: *style.StyledWriter, widths: [column_count]usize) !void { + try writer.writeStyle(style.role.status); + var wrote_any = false; + for (table_layout.list_detail_columns, 0..) |id, index| { + if (widths[index] == 0) continue; + if (wrote_any) try writer.out.writeAll(" | "); + wrote_any = true; + try table_layout.writeTruncatedPadded(writer.out, headerFor(id, widths[index]), widths[index]); } - try writeAlignedRow(writer, values[0..count], widths[0..count], minimums[0..count], max_cols, false, style.role.status, true); + try writer.reset(); + try writer.out.writeByte('\n'); } pub fn write( @@ -34,33 +62,88 @@ pub fn write( writer: *style.StyledWriter, account: registry.AccountRecord, row: anytype, - number_width: usize, - max_cols: ?usize, + widths: [column_count]usize, ) !void { - const visible_base = visibleBaseColumns(max_cols, number_width); const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); const token_expiry = try rows.formatTimestampAlloc(allocator, account.access_token_expires_at); defer allocator.free(token_expiry); const plan_expiry = try rows.formatFutureDateAlloc(allocator, account.plan_expires_at, now); defer allocator.free(plan_expiry); - const reset_credits_cell = try resetCreditsCell(allocator, account); - defer allocator.free(reset_credits_cell); + const reset_full = try resetCreditsCell(allocator, account); + defer allocator.free(reset_full); + const reset_short = shortResetCell(reset_full); + + const values = [_][]const u8{ + row.number, + row.account, + row.plan, + plan_expiry, + row.rate_5h, + row.rate_week, + row.last, + token_expiry, + reset_full, + }; + const row_style = if (row.number.len != 0 and row.number[0] == '!') style.role.error_text else if (row.number.len != 0 and row.number[0] == '*') style.role.success else ""; + try writer.writeStyle(row_style); + var wrote_any = false; + for (table_layout.list_detail_columns, 0..) |id, index| { + if (widths[index] == 0) continue; + if (wrote_any) { + if (row_style.len == 0) try writer.writeStyle(style.role.secondary); + try writer.out.writeAll(" | "); + if (row_style.len == 0) try writer.reset(); + try writer.writeStyle(row_style); + } + wrote_any = true; + switch (id) { + .account => try table_layout.writeAccountTruncatedPadded(writer.out, values[index], widths[index]), + .rate_5h, .rate_week => try writeUsageCell(writer.out, values[index], widths[index]), + .reset_credits => try writeResetCell(writer.out, reset_full, reset_short, widths[index]), + else => try table_layout.writeTruncatedPadded(writer.out, values[index], widths[index]), + } + } + try writer.reset(); + try writer.out.writeByte('\n'); +} - 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, plan_expiry, 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; +fn headerFor(id: table_layout.ColumnId, width: usize) []const u8 { + return switch (id) { + .number => "#*", + .account => "ACCOUNT", + .plan => "PLAN", + .credits => "CREDITS", + .reset_credits => if (width >= "RESET CREDITS".len) "RESET CREDITS" else if (width >= "RESET".len) "RESET" else if (width >= "RC".len) "RC" else "R", + .rate_5h => "5H", + .rate_week => "WEEKLY", + .last => "LAST", + .auth_expire => if (width >= "AUTH EXPIRE".len) "AUTH EXPIRE" else if (width >= "AUTH EXP".len) "AUTH EXP" else if (width >= "AUTH".len) "AUTH" else "AE"[0..@min(width, "AE".len)], + .plan_expire => if (width >= "PLAN EXPIRE".len) "PLAN EXPIRE" else if (width >= "PLAN EXP".len) "PLAN EXP" else if (width >= "PLAN".len) "PLAN" else "PE"[0..@min(width, "PE".len)], + }; +} + +fn writeUsageCell(out: *std.Io.Writer, value: []const u8, width: usize) !void { + if (std.mem.indexOfScalar(u8, value, '%')) |percent| { + const short = value[0 .. percent + 1]; + if (text_width.displayWidth(value) > width and text_width.displayWidth(short) <= width) { + try table_layout.writeTruncatedPadded(out, short, width); + return; + } } - 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); + try table_layout.writeTruncatedPadded(out, value, width); +} + +fn writeResetCell(out: *std.Io.Writer, full: []const u8, short: []const u8, width: usize) !void { + if (text_width.displayWidth(full) > width and width < 5 and text_width.displayWidth(short) <= width) { + try table_layout.writeTruncatedPadded(out, short, width); + return; + } + try table_layout.writeTruncatedPadded(out, full, width); +} + +fn shortResetCell(full: []const u8) []const u8 { + if (std.mem.indexOf(u8, full, " (")) |index| return full[0..index]; + return full; } /// `N` from the persisted `reset_credits` count, plus the earliest expiry among @@ -94,99 +177,5 @@ fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecor 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{ 8, 7, 6, 3, 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; + return std.fmt.allocPrint(allocator, "{d} (-)", .{value}); } diff --git a/src/cli/render.zig b/src/cli/render.zig index 22024f40..0778dbe2 100644 --- a/src/cli/render.zig +++ b/src/cli/render.zig @@ -422,11 +422,11 @@ pub fn renderListBody( 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); + const column_widths = details.computeWidths(number_width, widths, max_cols); + try details.writeHeader(writer, column_widths); var displayed_counter: usize = 0; for (rows) |row| { if (row.is_header) continue; @@ -444,7 +444,7 @@ pub fn renderListBody( .rate_5h = row.rate_5h, .rate_week = row.rate_week, .last = row.last, - }, number_width, max_cols); + }, column_widths); } } diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 01467e94..4646a060 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -12,7 +12,6 @@ pub const LiveListViewport = struct { }; pub const live_column_count = 5; -pub const column_count = 8; const live_account_ident_width: usize = 10; const account_min_width: usize = 2; const live_account_suffix_min_width: usize = 10; @@ -29,6 +28,39 @@ pub const Cell = struct { indent: usize = 0, }; +/// Semantic identity of a responsive table column. Visual order is supplied +/// separately, so views such as `list` and `list --live` can keep their own +/// column order while sharing one allocation strategy. +pub const ColumnId = enum { + number, + account, + plan, + credits, + reset_credits, + rate_5h, + rate_week, + last, + auth_expire, + plan_expire, +}; + +pub const ColumnSpec = struct { + id: ColumnId, + content_width: usize, + target_width: usize, + min_width: usize, + allow_partial: bool = false, +}; + +pub const ColumnLayout = struct { + columns: []const ColumnSpec, + first_order: []const ColumnId, + grow_order: []const ColumnId, + deferred_order: []const ColumnId, + prefix_width: usize, + separator_width: usize, +}; + pub const LiveTable = struct { columns: [live_column_count]LiveTableColumn, prefix_width: usize, @@ -94,115 +126,149 @@ pub const LiveTable = struct { } }; -pub fn accountTable(widths: [column_count]usize, prefix_width: usize) LiveTable { +pub fn accountTable(widths: [live_column_count]usize, prefix_width: usize) LiveTable { return .{ .columns = .{ .{ .header = "ACCOUNT", .width = widths[0] }, .{ .header = "PLAN", .width = widths[1] }, - .{ .header = "5H", .width = widths[4] }, - .{ .header = "WEEKLY", .width = widths[5] }, - .{ .header = "LAST", .width = widths[6] }, + .{ .header = "5H", .width = widths[2] }, + .{ .header = "WEEKLY", .width = widths[3] }, + .{ .header = "LAST", .width = widths[4] }, }, .prefix_width = prefix_width, }; } +pub const switch_columns = [_]ColumnId{ .account, .plan, .rate_5h, .rate_week, .last }; +pub const switch_first_order = [_]ColumnId{ .account, .rate_5h, .rate_week, .plan, .last }; +pub const switch_grow_order = [_]ColumnId{ .rate_5h, .rate_week, .plan, .last, .account }; +pub const switch_deferred_order = [_]ColumnId{}; + pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) LiveTable { - return accountTable( - boundColumnWidths(switchWidthsToColumns(widths), prefix_width, max_cols), - prefix_width, - ); + const source_widths = [_]usize{ widths.email, widths.plan, widths.rate_5h, widths.rate_week, widths.last }; + var columns: [switch_columns.len]ColumnSpec = undefined; + for (switch_columns, 0..) |id, index| { + columns[index] = columnSpec(id, source_widths[index]); + } + var bounded = [_]usize{0} ** live_column_count; + allocateColumns(.{ + .columns = &columns, + .first_order = &switch_first_order, + .grow_order = &switch_grow_order, + .deferred_order = &switch_deferred_order, + .prefix_width = prefix_width, + .separator_width = 2, + }, max_cols, &bounded); + return accountTable(bounded, prefix_width); } -const first_pass_order = [_]usize{ 0, 4, 5, 2, 1, 3, 6 }; -const grow_pass_order = [_]usize{ 2, 4, 5, 1, 3, 6, 0 }; -const deferred_column_index: usize = 7; -const column_target_widths = [_]usize{ - live_account_ident_width, // ACCOUNT - "PLAN".len, // PLAN - 2, // CREDITS - 2, // RESET CREDITS - @max(4, "5H".len), // 5H - "WEEKLY".len, // WEEKLY - 4, // LAST - 10, // PLAN EXPIRE -}; +pub const plain_columns = [_]ColumnId{ .account, .plan, .credits, .reset_credits, .rate_5h, .rate_week, .last, .plan_expire }; +pub const plain_first_order = [_]ColumnId{ .account, .rate_5h, .rate_week, .credits, .plan, .reset_credits, .last }; +pub const plain_grow_order = [_]ColumnId{ .credits, .rate_5h, .rate_week, .plan, .reset_credits, .last, .account }; +pub const plain_deferred_order = [_]ColumnId{.plan_expire}; + +pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .rate_5h, .rate_week, .last, .auth_expire, .reset_credits }; +pub const list_detail_first_order = [_]ColumnId{ .number, .account, .rate_5h, .rate_week, .plan, .last, .auth_expire, .reset_credits }; +pub const list_detail_grow_order = [_]ColumnId{ .auth_expire, .reset_credits, .plan, .last, .rate_5h, .rate_week, .account }; +pub const list_detail_deferred_order = [_]ColumnId{.plan_expire}; + +pub fn defaultTargetWidth(id: ColumnId) usize { + return switch (id) { + .number => 2, + .account => live_account_ident_width, + .plan => "PLAN".len, + .credits => 2, + .reset_credits => 2, + .rate_5h => @max(4, "5H".len), + .rate_week => "WEEKLY".len, + .last => 4, + .auth_expire => 10, + .plan_expire => 10, + }; +} + +pub fn columnSpec(id: ColumnId, content_width: usize) ColumnSpec { + const target = defaultTargetWidth(id); + return .{ + .id = id, + .content_width = content_width, + .target_width = target, + .min_width = if (id == .account) account_min_width else target, + .allow_partial = id == .account, + }; +} + +pub fn allocateColumns(layout: ColumnLayout, max_cols: ?usize, widths: []usize) void { + @memset(widths, 0); + if (widths.len != layout.columns.len) return; -/// Shared responsive column allocator for every table view. -/// -/// Input widths are content widths. A zero input width disables a column -/// (used by the live view to hide CREDITS and RESET CREDITS). The first pass -/// admits columns by priority only when their target width and separator fit; -/// ACCOUNT may use a partial width when nothing else fits. The second pass -/// grows visible columns toward their content width, keeping ACCOUNT last so -/// long labels cannot starve values such as `unlimited` or error names. -/// Columns that receive zero width must be skipped by the caller. -pub fn boundColumnWidths(widths: [column_count]usize, prefix_len: usize, max_cols: ?usize) [column_count]usize { - const cols = max_cols orelse return widths; - var bounded = [_]usize{0} ** column_count; - if (cols <= prefix_len) return bounded; - - var remaining = cols - prefix_len; + if (max_cols == null) { + for (layout.columns, 0..) |column, index| widths[index] = column.content_width; + return; + } + + const cols = max_cols.?; + if (cols <= layout.prefix_width) return; + + var remaining = cols - layout.prefix_width; var visible_count: usize = 0; - // First pass: reserve targets by priority. Separators are charged only - // when a column actually becomes visible. - for (first_pass_order) |index| { - const target = @min(widths[index], column_target_widths[index]); + for (layout.first_order) |id| { + const index = columnIndex(layout.columns, id) orelse continue; + const column = layout.columns[index]; + if (column.content_width == 0) continue; + + const target = @min(column.content_width, column.target_width); if (target == 0) continue; - if (index == 0) { - const width = @min(target, remaining); - if (width < account_min_width) continue; - bounded[index] = width; - remaining -= width; + const separator = if (visible_count == 0) 0 else layout.separator_width; + + if (column.allow_partial) { + if (remaining < column.min_width + separator) break; + const width = @min(target, remaining - separator); + if (width < column.min_width) break; + widths[index] = width; + remaining -= width + separator; visible_count += 1; continue; } - const separator_width: usize = if (visible_count == 0) 0 else 2; - if (remaining < target + separator_width) continue; - bounded[index] = target; - remaining -= target + separator_width; + if (remaining < target + separator) break; + widths[index] = target; + remaining -= target + separator; visible_count += 1; } - // Second pass: grow the variable-length credit cell first so values like - // `unlimited` do not collapse to `u.`, then the usage windows, and keep - // account identity last so long labels cannot starve other values. - for (grow_pass_order) |index| { - if (bounded[index] == 0) continue; - growBoundedWidth(&remaining, &bounded[index], widths[index]); + for (layout.grow_order) |id| { + const index = columnIndex(layout.columns, id) orelse continue; + if (widths[index] == 0) continue; + growBoundedWidth(&remaining, &widths[index], layout.columns[index].content_width); } - // Deferred pass: admit a low-priority column only after every existing - // column has had a chance to grow. This keeps the plan-expiry column from - // stealing width from usage and status cells on mid-sized terminals. - if (bounded[deferred_column_index] == 0 and widths[deferred_column_index] > 0) { - const separator_width: usize = if (visible_count == 0) 0 else 2; - if (remaining > separator_width) { - const width = @min(widths[deferred_column_index], remaining - separator_width); - if (width > 0) { - bounded[deferred_column_index] = width; - remaining -= width + separator_width; - } - } + // Deferred columns are admitted only after all normal columns have grown. + // They still require their target width, so they never become dot-only + // cells; if the target does not fit, the column stays hidden. + for (layout.deferred_order) |id| { + const index = columnIndex(layout.columns, id) orelse continue; + if (widths[index] != 0) continue; + const column = layout.columns[index]; + if (column.content_width == 0) continue; + const target = @min(column.content_width, column.target_width); + if (target == 0) continue; + const separator = if (visible_count == 0) 0 else layout.separator_width; + if (remaining < target + separator) continue; + widths[index] = target; + remaining -= target + separator; + visible_count += 1; + growBoundedWidth(&remaining, &widths[index], column.content_width); } - - return bounded; } -pub fn switchWidthsToColumns(widths: SwitchWidths) [column_count]usize { - return .{ - widths.email, - widths.plan, - 0, - 0, - widths.rate_5h, - widths.rate_week, - widths.last, - 0, - }; +fn columnIndex(columns: []const ColumnSpec, id: ColumnId) ?usize { + for (columns, 0..) |column, index| { + if (column.id == id) return index; + } + return null; } fn growBoundedWidth(remaining: *usize, current: *usize, target: usize) void { diff --git a/src/tui/table.zig b/src/tui/table.zig index edc15773..782fb8dd 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -188,9 +188,21 @@ pub fn writeAccountsTableWithUsageOverridesForCols( } } - const widths = table_layout.boundColumnWidths(content_widths, prefix_len, term_cols); - var visible: [8]bool = undefined; - var header_cells: [8][]const u8 = undefined; + var columns: [table_layout.plain_columns.len]table_layout.ColumnSpec = undefined; + for (table_layout.plain_columns, 0..) |id, index| { + columns[index] = table_layout.columnSpec(id, content_widths[index]); + } + var widths = [_]usize{0} ** table_layout.plain_columns.len; + table_layout.allocateColumns(.{ + .columns = &columns, + .first_order = &table_layout.plain_first_order, + .grow_order = &table_layout.plain_grow_order, + .deferred_order = &table_layout.plain_deferred_order, + .prefix_width = prefix_len, + .separator_width = sep_len, + }, term_cols, &widths); + var visible: [table_layout.plain_columns.len]bool = undefined; + var header_cells: [table_layout.plain_columns.len][]const u8 = undefined; for (widths, 0..) |width, index| { visible[index] = width > 0; const full = headers[index]; diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index fa137722..33b3f101 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1775,3 +1775,42 @@ test "live list distinguishes unavailable reset details from an empty server lis 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); } + +test "live list defers PLAN EXPIRE and keeps the configured column order" { + const gpa = std.testing.allocator; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "very-long-account-name@example.com", "very-long-account-name", .pro); + reg.accounts.items[0].plan_expires_at = now + 30 * 86400; + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 0, .window_minutes = 300, .resets_at = now + 3600 }, + .secondary = .{ .used_percent = 42, .window_minutes = 10080, .resets_at = now + 604800 }, + .credits = null, + .plan_type = .pro, + .reset_credits = 3, + }; + var rows = try buildSwitchRows(gpa, ®); + defer rows.deinit(gpa); + + var narrow_body: std.Io.Writer.Allocating = .init(gpa); + defer narrow_body.deinit(); + var narrow_writer = StyledWriter.init(&narrow_body.writer, false); + try cli.render.renderListBody(gpa, &narrow_writer, ®, rows.items, 2, rows.widths, 120); + try std.testing.expect(std.mem.indexOf(u8, narrow_body.written(), "PLAN EXPIRE") == null); + var lines = std.mem.splitScalar(u8, narrow_body.written(), '\n'); + while (lines.next()) |line| try std.testing.expect(line.len <= 120); + + var wide_body: std.Io.Writer.Allocating = .init(gpa); + defer wide_body.deinit(); + var wide_writer = StyledWriter.init(&wide_body.writer, false); + try cli.render.renderListBody(gpa, &wide_writer, ®, rows.items, 2, rows.widths, 200); + const body = wide_body.written(); + try std.testing.expect(std.mem.indexOf(u8, body, "PLAN EXPIRE") != null); + const plan = std.mem.indexOf(u8, body, "PLAN") orelse return error.TestUnexpectedResult; + const expire = std.mem.indexOf(u8, body, "PLAN EXPIRE") orelse return error.TestUnexpectedResult; + const five_hour = std.mem.indexOf(u8, body, "5H") orelse return error.TestUnexpectedResult; + try std.testing.expect(plan < expire and expire < five_hour); + lines = std.mem.splitScalar(u8, body, '\n'); + while (lines.next()) |line| try std.testing.expect(line.len <= 200); +} diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 881fdc46..b8720b7e 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -418,3 +418,38 @@ test "writeAccountsTable shows plan expiry for future data and hides expired dat try std.testing.expect(std.mem.indexOf(u8, output, expired_text[0..10]) == null); } } + +test "writeAccountsTable defers PLAN EXPIRE on narrow widths" { + const gpa = std.testing.allocator; + const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user-1::acc-1", "very-long-account-name@example.com", "very-long-account-name", .plus); + reg.accounts.items[0].plan_expires_at = now + 30 * 86400; + reg.accounts.items[0].last_usage = .{ + .primary = .{ .used_percent = 0, .window_minutes = 300, .resets_at = now + 3600 }, + .secondary = .{ .used_percent = 42, .window_minutes = 10080, .resets_at = now + 604800 }, + .credits = null, + .plan_type = .plus, + }; + + var overrides = [_]?[]const u8{"FileNotFound"}; + var buffer: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, &overrides, 70); + var output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "PLAN EXPIRE") == null); + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |line| try std.testing.expect(line.len <= 70); + + const expected = try codex_auth.cli.rows.formatFutureDateAlloc(gpa, reg.accounts.items[0].plan_expires_at, now); + defer gpa.free(expected); + buffer = undefined; + writer = .fixed(&buffer); + try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, &overrides, 200); + output = writer.buffered(); + try std.testing.expect(std.mem.indexOf(u8, output, "PLAN EXPIRE") != null); + try std.testing.expect(std.mem.indexOf(u8, output, expected) != null); + lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |line| try std.testing.expect(line.len <= 200); +} From 360289bd4965e69185b4e40a5fa17fbfe926ae4e Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 18:56:19 +0800 Subject: [PATCH 3/7] docs: replace schema migration guide with compatibility policy - Rename docs/schema-migration.md to docs/schema.md. - Define schema_version as a compatibility gate rather than a shape changelog. - Add data-authority, reader/writer, recovery, and mixed-fleet criteria. - Document optional derived/cache fields such as plan_expires_at. - Move migration details under a dedicated Migration section. - Update implementation and changelog references. --- CHANGELOG.md | 2 +- docs/implement.md | 7 +- docs/schema-migration.md | 106 ------------- docs/schema.md | 332 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 110 deletions(-) delete mode 100644 docs/schema-migration.md create mode 100644 docs/schema.md diff --git a/CHANGELOG.md b/CHANGELOG.md index cf0365d8..488e3a30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,5 +58,5 @@ JSON output, and several new account management commands. Added per-command documentation under [docs/commands/](./docs/commands/README.md), plus [docs/json-api.md](./docs/json-api.md), [docs/api.md](./docs/api.md), -[docs/schema-migration.md](./docs/schema-migration.md), and +[docs/schema.md](./docs/schema.md), and [docs/table-layout.md](./docs/table-layout.md). diff --git a/docs/implement.md b/docs/implement.md index a43d7ce1..facdae17 100644 --- a/docs/implement.md +++ b/docs/implement.md @@ -7,7 +7,7 @@ This document is the implementation index for `codex-auth`. Command-specific beh - Command behavior: [docs/commands/README.md](./commands/README.md) - API refresh and endpoint rules: [docs/api.md](./api.md) - File permissions: [docs/permissions.md](./permissions.md) -- Schema migration: [docs/schema-migration.md](./schema-migration.md) +- Registry schema and compatibility: [docs/schema.md](./schema.md) - Test organization: [docs/tests.md](./tests.md) - Release and CI: [docs/release.md](./release.md) @@ -31,14 +31,15 @@ Managed files: ## Registry Compatibility -- `registry.json.schema_version` is the on-disk migration gate. +- `registry.json.schema_version` is the on-disk compatibility gate. - `version = 2` registries using `active_email` and email-keyed snapshots are migrated to the current schema. - Current-layout files that still use the top-level `version = 3` key are rewritten to the current schema. - Loading a supported older schema performs the migration in memory and rewrites `registry.json` in the current format. - Loading a newer `schema_version` is rejected with `UnsupportedRegistryVersion`. +- Additive optional derived/cache fields stay within the current schema when missing data is safe and recoverable; see [docs/schema.md](./schema.md). - Saving always rewrites `registry.json` into the current schema field set. -See [docs/schema-migration.md](./schema-migration.md) for versioning policy and migration rules. +See [docs/schema.md](./schema.md) for the compatibility policy, data classification, and migration rules. ## Account Identity diff --git a/docs/schema-migration.md b/docs/schema-migration.md deleted file mode 100644 index 933aab9a..00000000 --- a/docs/schema-migration.md +++ /dev/null @@ -1,106 +0,0 @@ -# Schema Migration - -This document defines how `codex-auth` versions the on-disk `~/.codex/accounts/registry.json` file. - -## Terms - -- `app_version` is the CLI release version from `src/version.zig`. -- `schema_version` is the registry file format version stored in `registry.json`. -- `schema_version` is about migration only; it is not the same thing as the CLI release version. - -## 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 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`. -- 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`. -- Users are not expected to install intermediate `codex-auth` versions. - -## Schema History - -- `version = 2` - - Email-keyed account snapshots - - `active_email` - - Email-based account identity -- `schema_version = 3` - - Record-key-based account snapshots - - `active_account_key` - - `active_account_activated_at_ms` - - Per-account `last_local_rollout` - - Legacy top-level `api` block, now ignored and omitted on rewrite - - Per-account `account_key` - - Each account also stores `chatgpt_account_id` and `chatgpt_user_id` -- `schema_version = 4` - - Same account layout as schema `3` - - Live refresh interval stored as top-level `interval_seconds` - - Older `live.interval_seconds` and removed `auto_switch` blocks are omitted on rewrite - - Adds top-level `previous_active_account_key` for `codex-auth -` and `codex-auth switch -` - - Persisted plans use final product semantics rather than backend identifiers - - Legacy `team` becomes `business` - - Legacy `business` becomes `enterprise` - - The same conversion applies to per-account `last_usage.plan_type` - -## When To Bump `schema_version` - -Before bumping `schema_version`, ask the user whether this change should bump the schema. Do not bump it silently. - -The rationale behind this policy — including why purely additive fields count as a bump here — is in [Versioning Rationale](#versioning-rationale) below. - -Bump the schema version whenever the persisted `registry.json` shape or semantics change. That includes: - -- Adding, removing, or renaming a persisted field -- Changing a field type -- Changing identity keys such as `active_email` to `active_account_key` -- Changing snapshot filename conventions or any other rule needed to find persisted files -- Reinterpreting an existing field with incompatible semantics - -Do not bump the schema version for: - -- CLI output changes -- Pure in-memory logic changes -- Help text or documentation changes -- Runtime behavior changes that do not alter persisted registry data - -## Versioning Rationale - -The `schema_version` number exists for **old binaries**, not new ones. The decision to bump is not "did the shape change" but "would an older binary behave incorrectly against a file written by this one". In practice: - -- **Backward compatibility** (new binary reads old data) is guaranteed by the migration chain above. -- **Forward compatibility** (old binary reads new data) is deliberately **not** attempted: a file with a newer `schema_version` is rejected instead of being interpreted with partial knowledge. - -### The rewriting-proxy hazard - -`codex-auth` never edits `registry.json` in place. Every mutation (sync, switch, import, migration) loads the whole file and rewrites it wholesale. A binary that does not know a field therefore **strips that field silently** on its next write. This "lossy proxy" behavior is why tolerant readers alone are not enough here, and why the project chooses fail fast: a newer file is refused with `UnsupportedRegistryVersion` rather than read-and-rewritten, because **explicit breakage beats silent data loss** for a tool that manages credentials. - -### Why additive fields still bump - -General industry practice often lets tolerant readers absorb additive optional fields without a version bump, and that is defensible when the added data is refetchable and old/new versions rarely coexist. This repository takes the stricter side on purpose: - -- The rewriting proxy above makes honest forward compatibility impossible. -- `registry.json` holds credential and identity state, where silent degradation is the worst failure mode. -- The migration machinery (auto-migrate plus `registry.json.bak.*` backups) makes a bump cheap and reversible. - -### Decision checklist - -1. **Old read**: would an older binary misinterpret (not merely miss) the new data? Misinterpretation always requires a bump. -2. **Old write**: if an older binary rewrote the file, what is lost — and does it self-heal (refetchable) or not (source of truth)? Non-healing loss requires a bump; healing-only loss is the judgment call this document resolves toward bumping. -3. **Mixed fleet**: do old and new binaries realistically run against the same file (downgrades, parallel installs)? The more likely, the stronger the case for bumping. - -If any test fails, bump. When in doubt, bump: the migration chain keeps the cost near zero. - -Version numbers stay a single monotonic integer. File formats do not carry semver; consumers only need to know whether they understand the file, not how much it changed. - -## Migration Rules - -- A supported older schema must auto-migrate on load and then rewrite `registry.json` in the current format. -- Supported migrations should preserve account records, active account selection, and account snapshot usability. -- Migration rewrites create the usual `registry.json.bak.*` backup before replacing the file. -- `import --purge` remains a manual recovery path if a registry is corrupted or too old for the current binary, but it is not the normal path between supported schemas. diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 00000000..dbabaed8 --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,332 @@ +# Registry Schema + +This document defines the on-disk format of +`/accounts/registry.json`, the compatibility rules that protect it, +and the migration process. + +## Scope + +- `app_version` is the CLI release version from `src/version.zig`. +- `schema_version` is the registry format compatibility gate stored in + `registry.json`. +- `schema_version` is not the same thing as `app_version`. +- `list --json` has its own `schema_version`; see + [json-api.md](./json-api.md). + +`schema_version` is not a changelog and not a hash of the file shape. It +answers one question: + +> May an older binary safely read and rewrite this file? + +## Compatibility Model + +### Backward compatibility + +New binaries must be able to read old registries. Supported old layouts are +migrated on load and rewritten in the current format. + +### Forward compatibility + +Old binaries must not silently corrupt or degrade newer registries. + +The current implementation is intentionally fail-fast: + +- a registry with a newer `schema_version` is rejected with + `UnsupportedRegistryVersion`; +- the rejected file is not rewritten. + +This protects authoritative state, but it also means a bump is a hard lockout +for older binaries. A bump is therefore a compatibility decision, not a routine +way to record a new field. + +### Round-trip compatibility + +The current implementation does **not** preserve unknown keys. Every mutation +loads the registry into the typed model and rewrites the whole file. A binary +that accepts a file but does not know a field will drop that field on the next +write. + +This is the "rewriting-proxy" hazard: + +```text +older binary reads a same-version file + -> parses known fields + -> rewrites the whole file + -> unknown fields are silently lost +``` + +Consequences: + +- additive fields must either be recoverable, or require a bump; +- adding a field is not automatically safe just because it is optional; +- the data class matters as much as the field shape. + +### What `schema_version` means + +- It is a single monotonic integer. +- It is a compatibility gate, not a feature list. +- A bump means: "older binaries must not read or rewrite this file." +- A no-bump additive change means: "older binaries may ignore or drop this + data; the new binary can tolerate its absence." + +Do not bump merely to announce a new optional field. Do not skip a bump for a +change that old binaries can misread. + +## Change Classification + +| Change | Typical impact | Version action | +| --- | --- | --- | +| Remove a persisted field | old code loses data or fails | bump | +| Rename a persisted field | old code cannot find the value | bump | +| Change a field type | parse failure or silent coercion | bump | +| Change units, encoding, or timezone semantics | values look valid but mean something else | bump | +| Change the default for a missing field | behavior changes without a shape change | bump | +| Change identity keys or snapshot filenames | data cannot be matched or found | bump | +| Add a required field | old files cannot satisfy the new contract | bump + migration | +| Add an optional field | may be safe; depends on reader/writer behavior and data class | decide | +| Add an enum value | safe only if old readers map unknown values to `unknown` | usually no bump | +| Add a derived or cache field | loss is recoverable | usually no bump | +| Change CLI output, logs, help, or docs | not persisted state | no bump | + +### Optional fields + +"Optional" is necessary but not sufficient. An optional field still needs a +decision when an older writer drops it. + +Safe no-bump cases generally have all of these properties: + +- the loader treats a missing value as `null` or another safe default; +- the field does not change the meaning of existing fields; +- an older reader ignores the field rather than misreading it; +- the value is derived, cached, or otherwise recoverable; +- the recovery path is documented and tested. + +A bump is required when any of these is true: + +- an older reader would misinterpret the value; +- the field is authoritative and cannot be reconstructed; +- the field is required for correct operation; +- the field changes existing identity or lookup rules. + +## Decision Criteria + +### 1. Semantic safety + +Ask whether an older binary would **misread** the new data, not merely miss it. + +- ignoring an unknown field is usually safe; +- treating a seconds value as milliseconds is not; +- mapping an unknown enum to a wrong known value is not; +- accepting a renamed field as a different field is not. + +### 2. Data authority + +Classify the data before deciding the version action. + +| Data class | Examples | Loss behavior | Version action | +| --- | --- | --- | --- | +| Authoritative identity | account key, ChatGPT account id, user id | cannot be reconstructed | bump or preserve unknown fields | +| User-authored configuration | aliases, account names, API config, refresh interval | user must re-enter it | bump or preserve unknown fields | +| Derived metadata | plan, auth mode, `access_token_expires_at`, `plan_expires_at` | recoverable from auth or usage data | no bump if missing is safe | +| Usage cache | `last_usage`, `last_usage_at`, `reset_credit`, `last_local_rollout` | refetched or re-derived | no bump if missing is safe | +| Display-only data | derived labels and timestamps | temporary degradation | no bump | + +If the data is authoritative and an older writer would drop it, the options are: + +1. bump `schema_version`; or +2. implement unknown-field preservation so old writers round-trip it. + +Do not rely on "the field is optional" alone. + +### 3. Reader and writer behavior + +Check both sides: + +- Does the old reader ignore unknown keys, reject them, or misinterpret them? +- Does the old writer preserve unknown keys or rewrite from a typed model? +- Does the new reader require the field, or can it degrade gracefully? +- Does the new writer preserve unknown fields for future versions? + +The current answer is: + +- old readers reject newer `schema_version`; +- old writers may drop unknown fields at the same version; +- new readers use safe defaults for optional fields; +- unknown-field preservation is not implemented yet. + +### 4. Recovery + +For a derived or cache field, answer: + +- Where does the value come from? +- Can it be rebuilt without network access? +- On which command or refresh path is it rebuilt? +- What does the UI show before it is rebuilt? +- Is the loss observable and temporary, or silent and permanent? + +`plan_expires_at`, for example, is rebuilt from the stored `id_token` on auth +sync and from the usage refresh path. A missing value renders as `-` until the +next refresh. That is a temporary display degradation, not credential loss. + +### 5. Mixed fleet and cost + +A bump is a hard lockout for older binaries. Consider: + +- downgrades and parallel installs; +- package managers that keep an older binary; +- users who share or sync a Codex home; +- the cost of forcing every reader to upgrade for an optional field. + +If an old binary rejecting the file would be more harmful than losing a +recoverable cache value, prefer no bump plus a documented recovery path. + +## Versioning Policy + +### Must bump + +- Removing, renaming, or changing the type of a persisted field. +- Changing units, encoding, or timezone semantics. +- Changing the meaning of an existing field or its missing-value default. +- Changing identity keys, snapshot filenames, or lookup paths. +- Adding a required field. +- Any change an older binary would misinterpret. +- Any authoritative state that cannot be reconstructed and would be dropped by + an older writer. + +### May skip the bump + +- An optional field with a safe missing-value default. +- A field that is derived, cached, or refetchable. +- A field that does not change existing semantics. +- An enum value that older readers safely map to `unknown`. +- Output, logging, help text, or documentation changes. + +A no-bump decision must document: + +- the source of truth; +- the recovery path; +- the behavior while the field is absent; +- the tests that cover an older registry without the field. + +### Requires more than a bump + +Some changes need a mechanism, not only a version number: + +- An authoritative field that older writers would drop: + implement unknown-field preservation, or bump. +- A mixed-version fleet that must keep working: + add a capability or `min_reader_version` marker, or keep additive fields + recoverable. +- A large derived payload: + put it in a separate cache section or file with its own version and an + explicit "safe to discard" contract. + +### Never do without a bump + +- Reuse a field name for different semantics. +- Change a timestamp from seconds to milliseconds. +- Change an identity key while keeping the old key. +- Treat missing data as a different plan, account, or entitlement. +- Silently reinterpret an existing field. + +## Current Schema + +The current persisted format is `schema_version = 4`. + +The schema `4` account layout is the same as schema `3`, with these +differences: + +- live refresh interval stored as top-level `interval_seconds`; +- older `live.interval_seconds` and removed `auto_switch` blocks are omitted on + rewrite; +- top-level `previous_active_account_key` supports `codex-auth -` and + `codex-auth switch -`; +- persisted plans use final product semantics rather than backend identifiers; +- legacy `team` becomes `business`; +- legacy `business` becomes `enterprise`; +- the same conversion applies to per-account `last_usage.plan_type`. + +Optional derived fields have been added to schema `4` without a bump, including +`access_token_expires_at`, `last_usage.reset_credit`, and `plan_expires_at`. +They are missing-as-null, recoverable from auth or usage data, and do not change +the meaning of existing fields. They are cache additions within the schema `4` +compatibility boundary, not a new registry format. + +## Migration + +This section covers the migration contract for older registries. + +### Supported paths + +- `schema_version = 3 -> schema_version = 4` is the guaranteed automatic + migration. +- The legacy `version = 2` loader is retained and rewrites directly to schema + `4`; it is not the compatibility boundary for current development. +- Current-layout files that still use the old top-level `version = 3` key, or + still carry the old global `last_attributed_rollout` shape, are normalized to + schema `4`. +- Users are expected to upgrade directly to the latest binary; intermediate + releases are not required. +- A `schema_version` newer than the current binary is rejected with + `UnsupportedRegistryVersion` and is not rewritten. + +### Migration rules + +- Load the old layout, migrate in memory, then rewrite `registry.json` in the + current format. +- Preserve account records, active account selection, previous active account + selection, and account-snapshot usability. +- Create the usual `registry.json.bak.*` backup before replacing the file. +- Migration may reset derived or cache fields that cannot be reconstructed from + the old layout; it must not silently drop authoritative identity or user + configuration. +- If the file cannot be migrated safely, fail without writing. +- Do not write a file whose version the binary does not understand. + +### Failure behavior + +- A newer `schema_version` fails with `UnsupportedRegistryVersion` before any + write. +- A malformed or unsupported older layout must not be converted into a + partially valid registry. +- The original file remains available, and the normal backup and `clean` + recovery paths still apply. + +## Adding or Changing a Persisted Field + +Use this checklist before changing `registry.json`: + +1. Classify the data as authoritative, user-authored, derived, or cache. +2. Check whether an older reader would ignore or misread the new value. +3. Check whether an older writer would drop it on rewrite. +4. Decide bump or no bump using the criteria above. +5. For a no-bump change: + - treat missing data as `null` or a safe default; + - document the source of truth and recovery path; + - test loading an older registry without the field; + - test the display or behavior while the field is absent. +6. For a bump: + - add the migration step and backup behavior; + - preserve authoritative records; + - test old layouts, current layouts, and a too-new layout; + - update the current schema version and history. +7. Update the JSON contract when the field is exposed by `list --json`. +8. Run the normal build, test, and CLI validation steps. + +Before bumping, confirm the decision with the maintainer. Do not bump silently, +and do not leave the document and the implementation on different policies. + +## JSON Output Schema + +The `list --json` document has its own `schema_version = 1`, documented in +[json-api.md](./json-api.md). It is separate from the registry version. + +Additive optional JSON fields do not require a schema bump: clients are required +to ignore unknown fields, and the new fields are optional. Registry compatibility +and JSON compatibility are independent contracts. + +## Related Documents + +- JSON output contract: [json-api.md](./json-api.md) +- Implementation index: [implement.md](./implement.md) +- Dynamic column layout: [dynamic-columns.md](./dynamic-columns.md) From de9d04a6ae281de7f48e06f11935a661a9869515 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Thu, 22 Oct 2026 19:03:07 +0800 Subject: [PATCH 4/7] fix(table): share priority order between list and live Use one shared first/grow/deferred order for every view. CREDITS now sits just above the deferred expiry fields, so expiry columns hide first, then CREDITS, then the remaining metadata. Unsupported columns are skipped without changing the shared order. Update the dynamic-columns document and add a regression test for CREDITS hiding before LAST. --- docs/dynamic-columns.md | 109 ++++++++++++++++++++++++------------ docs/table-layout.md | 6 +- src/cli/list_details.zig | 6 +- src/cli/table_layout.zig | 39 +++++++++---- src/tui/table.zig | 6 +- tests/table_layout_test.zig | 30 ++++++++-- 6 files changed, 134 insertions(+), 62 deletions(-) diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md index 651b3fff..e1d833db 100644 --- a/docs/dynamic-columns.md +++ b/docs/dynamic-columns.md @@ -3,17 +3,14 @@ All table views use one responsive allocator in `src/cli/table_layout.zig`. The allocator separates a column's semantic identity from its position on screen, so each view can keep its own visual order while sharing the same width -policy. +policy and hide order. ## Model Each view supplies: - `columns`: the visual order and content width of its supported columns; -- `first_order`: columns that reserve a useful target width first; -- `grow_order`: visible columns that may grow toward their content width; -- `deferred_order`: low-priority columns admitted only after every normal - column has had a chance to grow; +- `first_order`, `grow_order`, `deferred_order`: the shared priority lists; - `prefix_width`: width of the row prefix (`#`, checkbox, index); - `separator_width`: width of the separator between visible columns. @@ -23,7 +20,7 @@ Their width and separator budget is redistributed to the remaining columns. ## Visual Order -The order of each view is fixed and independent of priority: +The order of each view is fixed. Unsupported columns are simply omitted. | View | Visual order | |---|---| @@ -31,50 +28,91 @@ The order of each view is fixed and independent of priority: | `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `5H`, `WEEKLY`, `LAST`, `AUTH EXPIRE`, `RESET CREDITS` | | `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | -`switch` and `remove` hide `CREDITS`, `RESET CREDITS`, `AUTH EXPIRE`, and -`PLAN EXPIRE`. The plain `list` hides `AUTH EXPIRE`. +## Shared Priority -## Allocation Phases +Every view uses the same ordered lists. A view with an unsupported column +(content width `0`) skips that entry without changing the order. -1. **Reserve targets** in `first_order`. A column is admitted only when its - target width and separator fit. `ACCOUNT` may use a partial width down to - 2 columns; hidden columns are not emitted as a missing separator or a - one-column dot. -2. **Grow** visible columns in `grow_order` toward their content width. -3. **Admit deferred columns** in `deferred_order`, in order, only when their - target width and separator still fit after every normal column has grown. +### Reserve order (highest to lowest) -Separators are charged only when the next column becomes visible. A line never -exceeds the requested terminal width. +```text +#* -> ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) + -> RESET CREDITS (2) -> LAST (4) -> CREDITS (2) +``` -## Per-View Priority +`PLAN EXPIRE` and `AUTH EXPIRE` are not in this list; they are deferred. -### Plain `list` +### Grow order ```text -reserve: ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> CREDITS (2) - -> PLAN (4) -> RESET CREDITS (2) -> LAST (4) -grow: CREDITS -> 5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> ACCOUNT -deferred: PLAN EXPIRE (10) +5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> CREDITS -> ACCOUNT ``` -### `list --live` +### Deferred order ```text -reserve: #* -> ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) - -> LAST (4) -> AUTH EXPIRE (10) -> RESET CREDITS (2) -grow: AUTH EXPIRE -> RESET CREDITS -> PLAN -> LAST -> 5H -> WEEKLY -> ACCOUNT -deferred: PLAN EXPIRE (10) +AUTH EXPIRE (10) -> PLAN EXPIRE (10) ``` -### `switch`, `remove` +Deferred columns are considered only after every normal column has grown. Each +still needs its target width and separator to fit, so deferred columns never +become one-column dot cells. + +## Shared Hide Order + +From wide to narrow, columns disappear in the reverse priority order. The +complete shared order is: ```text -reserve: ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) -> LAST (4) -grow: 5H -> WEEKLY -> PLAN -> LAST -> ACCOUNT -deferred: none +PLAN EXPIRE +-> AUTH EXPIRE +-> CREDITS +-> LAST +-> RESET CREDITS +-> PLAN +-> WEEKLY +-> 5H +-> ACCOUNT +-> #* ``` +Unsupported columns are already absent from a view: + +- plain `list` has no `AUTH EXPIRE` and no `#*`: `PLAN EXPIRE -> CREDITS -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. +- `list --live` has no `CREDITS`: `PLAN EXPIRE -> AUTH EXPIRE -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT -> #*`. +- `switch` / `remove` support none of the credits, auth-expiry, or plan-expiry columns: `LAST -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. + +`CREDITS` was intentionally placed just above the expiry fields: it is a +low-priority metadata column. `RESET CREDITS`, which carries the actionable +reset count, stays above it. + +## Allocation Phases + +1. **Reserve targets** in `first_order`. A column is admitted only when its + target width and separator fit. `ACCOUNT` may use a partial width down to + 2 columns. +2. **Grow** visible columns in `grow_order` toward their content width. +3. **Admit deferred columns** in `deferred_order`, only when their target + width and separator still fit after every normal column has grown. + +Separators are charged only when a column becomes visible. A line never exceeds +the requested terminal width. + +## Target Widths + +| Column | Target | +|---|---:| +| `#*` | index width (at least `#*`) | +| `ACCOUNT` | 10 (minimum 2) | +| `PLAN` | 4 | +| `PLAN EXPIRE` | 10, deferred | +| `CREDITS` | 2 | +| `RESET CREDITS` | 2 | +| `5H` | 4 | +| `WEEKLY` | 6 | +| `LAST` | 4 | +| `AUTH EXPIRE` | 10, deferred | + ## Headers Wide headers use their full label. Narrow columns use compact labels: @@ -91,9 +129,8 @@ Wide headers use their full label. Narrow columns use compact labels: 1. Add a `ColumnId` and its default target width. 2. Add it to the visual order of each view that supports it. Unsupported views pass content width `0`. -3. Put it in `first_order`, `grow_order`, or `deferred_order` according to its - importance. A new low-priority metadata column normally belongs in - `deferred_order`, not in a hardcoded special case in the allocator. +3. Add it to the shared `first_order`, `grow_order`, or `deferred_order` + according to its priority. 4. Add width-aware cell formatting and a compact header when needed. 5. Add a width-sweep test for hidden/visible behavior, line length, and any value that must stay complete (for example `100%`). diff --git a/docs/table-layout.md b/docs/table-layout.md index a023ec65..d52eb30b 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -45,9 +45,9 @@ Plain `list` at 70 columns hides the deferred `PLAN EXPIRE` column so status and usage values stay readable: ```text -ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST --------------------------------------------------------- -very-long. Pro - - 100% 58% - +ACCOUNT PLAN CR RC 5H WEEKLY LAST +-------------------------------------------------- +very-long. Pro - - 100% 58% - ``` `list --live` at a medium width: diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index 9fac4566..d06118aa 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -35,9 +35,9 @@ pub fn computeWidths( var bounded = [_]usize{0} ** column_count; table_layout.allocateColumns(.{ .columns = &columns, - .first_order = &table_layout.list_detail_first_order, - .grow_order = &table_layout.list_detail_grow_order, - .deferred_order = &table_layout.list_detail_deferred_order, + .first_order = &table_layout.shared_first_order, + .grow_order = &table_layout.shared_grow_order, + .deferred_order = &table_layout.shared_deferred_order, .prefix_width = 0, .separator_width = separator_width, }, max_cols, &bounded); diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 4646a060..52cbc009 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -140,9 +140,30 @@ pub fn accountTable(widths: [live_column_count]usize, prefix_width: usize) LiveT } pub const switch_columns = [_]ColumnId{ .account, .plan, .rate_5h, .rate_week, .last }; -pub const switch_first_order = [_]ColumnId{ .account, .rate_5h, .rate_week, .plan, .last }; -pub const switch_grow_order = [_]ColumnId{ .rate_5h, .rate_week, .plan, .last, .account }; -pub const switch_deferred_order = [_]ColumnId{}; + +/// Shared priority for every view. A view simply skips columns it does not +/// support (content width 0). Keep this single list as the source of truth so +/// `list` and `list --live` cannot drift apart. +pub const shared_first_order = [_]ColumnId{ + .number, + .account, + .rate_5h, + .rate_week, + .plan, + .reset_credits, + .last, + .credits, +}; +pub const shared_grow_order = [_]ColumnId{ + .rate_5h, + .rate_week, + .plan, + .reset_credits, + .last, + .credits, + .account, +}; +pub const shared_deferred_order = [_]ColumnId{ .auth_expire, .plan_expire }; pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) LiveTable { const source_widths = [_]usize{ widths.email, widths.plan, widths.rate_5h, widths.rate_week, widths.last }; @@ -153,9 +174,9 @@ pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols var bounded = [_]usize{0} ** live_column_count; allocateColumns(.{ .columns = &columns, - .first_order = &switch_first_order, - .grow_order = &switch_grow_order, - .deferred_order = &switch_deferred_order, + .first_order = &shared_first_order, + .grow_order = &shared_grow_order, + .deferred_order = &shared_deferred_order, .prefix_width = prefix_width, .separator_width = 2, }, max_cols, &bounded); @@ -163,14 +184,8 @@ pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols } pub const plain_columns = [_]ColumnId{ .account, .plan, .credits, .reset_credits, .rate_5h, .rate_week, .last, .plan_expire }; -pub const plain_first_order = [_]ColumnId{ .account, .rate_5h, .rate_week, .credits, .plan, .reset_credits, .last }; -pub const plain_grow_order = [_]ColumnId{ .credits, .rate_5h, .rate_week, .plan, .reset_credits, .last, .account }; -pub const plain_deferred_order = [_]ColumnId{.plan_expire}; pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .rate_5h, .rate_week, .last, .auth_expire, .reset_credits }; -pub const list_detail_first_order = [_]ColumnId{ .number, .account, .rate_5h, .rate_week, .plan, .last, .auth_expire, .reset_credits }; -pub const list_detail_grow_order = [_]ColumnId{ .auth_expire, .reset_credits, .plan, .last, .rate_5h, .rate_week, .account }; -pub const list_detail_deferred_order = [_]ColumnId{.plan_expire}; pub fn defaultTargetWidth(id: ColumnId) usize { return switch (id) { diff --git a/src/tui/table.zig b/src/tui/table.zig index 782fb8dd..dbe28686 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -195,9 +195,9 @@ pub fn writeAccountsTableWithUsageOverridesForCols( var widths = [_]usize{0} ** table_layout.plain_columns.len; table_layout.allocateColumns(.{ .columns = &columns, - .first_order = &table_layout.plain_first_order, - .grow_order = &table_layout.plain_grow_order, - .deferred_order = &table_layout.plain_deferred_order, + .first_order = &table_layout.shared_first_order, + .grow_order = &table_layout.shared_grow_order, + .deferred_order = &table_layout.shared_deferred_order, .prefix_width = prefix_len, .separator_width = sep_len, }, term_cols, &widths); diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index 76788d3d..f1051e5e 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -246,7 +246,7 @@ test "Scenario: Given compact list widths when rendering then headers use CR, RC var output = writer.buffered(); try expectLinesWithin(output, 40); try std.testing.expect(std.mem.indexOf(u8, output, "PLAN") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "CR") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "RC") != null); buffer = undefined; writer = .fixed(&buffer); @@ -263,10 +263,9 @@ test "Scenario: Given compact list widths when rendering then headers use CR, RC output = writer.buffered(); try expectLinesWithin(output, 60); try std.testing.expect(std.mem.indexOf(u8, output, "RESET") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "RESET CREDITS") == null); } -test "Scenario: Given unlimited credits when rendering at 60 columns then the value is not collapsed to u." { +test "Scenario: Given unlimited credits when rendering at 120 columns then the value is not collapsed to u." { const gpa = std.testing.allocator; var reg = makeTestRegistry(); defer reg.deinit(gpa); @@ -279,9 +278,30 @@ test "Scenario: Given unlimited credits when rendering at 60 columns then the va var buffer: [4096]u8 = undefined; var writer: std.Io.Writer = .fixed(&buffer); - try table_format.writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 60); + try table_format.writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 120); const output = writer.buffered(); - try expectLinesWithin(output, 60); + try expectLinesWithin(output, 120); try std.testing.expect(std.mem.indexOf(u8, output, "unlimited") != null); try std.testing.expect(std.mem.indexOf(u8, output, "u.") == null); } + +test "Scenario: Given low-priority metadata when narrowing then CREDITS hides before LAST" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendListAccount(gpa, ®, "very-long-account-name@example.com"); + reg.accounts.items[0].last_usage.?.credits = .{ + .has_credits = true, + .unlimited = true, + .balance = null, + }; + + var buffer: [4096]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try table_format.writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 48); + const output = writer.buffered(); + try expectLinesWithin(output, 48); + try std.testing.expect(std.mem.indexOf(u8, output, "LAST") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "CREDITS") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "CR") == null); +} From 43f02302e7cb6f4c2e7b0e25977fd7fabcd065c2 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 16:13:13 +0800 Subject: [PATCH 5/7] fix(list): restore auth expiry column in plain list Show both PLAN EXPIRE and AUTH EXPIRE in plain list output. Both remain deferred columns, so they still hide before CREDITS and other normal columns. --- docs/commands/list.md | 12 ++++++------ docs/dynamic-columns.md | 4 ++-- docs/table-layout.md | 12 ++++++------ src/cli/table_layout.zig | 2 +- src/tui/table.zig | 14 +++++++++++--- tests/tui_table_test.zig | 5 +++++ 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 88e83e0e..9c93bd0f 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -17,8 +17,8 @@ codex-auth list --json - Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable. - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. -- Non-live output shows `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, and `PLAN EXPIRE`. - The deferred `PLAN EXPIRE` column appears only when the terminal is wide enough. +- Non-live output shows `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, `PLAN EXPIRE`, and `AUTH EXPIRE`. + The deferred `PLAN EXPIRE` and `AUTH EXPIRE` columns appear only when the terminal is wide enough. ## Refresh Modes @@ -28,10 +28,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 access-token expiry, the plan expiry, and, per - account, the available reset-credit count with the earliest card expiry. The - `AUTH EXPIRE`, `PLAN EXPIRE`, and `RESET CREDITS` columns render in local time; - the available reset credits are also expandable per account in the live view. +- List output shows the access-token expiry and plan expiry. Live output also + shows the per-account available reset-credit count with the earliest card + expiry. The `AUTH EXPIRE`, `PLAN EXPIRE`, and `RESET CREDITS` columns render + in local time; available reset credits are also expandable 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 EXPIRE` comes from the `chatgpt_subscription_active_until` claim in the stored `id_token`. Missing or diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md index e1d833db..e50465fb 100644 --- a/docs/dynamic-columns.md +++ b/docs/dynamic-columns.md @@ -24,7 +24,7 @@ The order of each view is fixed. Unsupported columns are simply omitted. | View | Visual order | |---|---| -| `list` | `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, `PLAN EXPIRE` | +| `list` | `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, `PLAN EXPIRE`, `AUTH EXPIRE` | | `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `5H`, `WEEKLY`, `LAST`, `AUTH EXPIRE`, `RESET CREDITS` | | `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | @@ -78,7 +78,7 @@ PLAN EXPIRE Unsupported columns are already absent from a view: -- plain `list` has no `AUTH EXPIRE` and no `#*`: `PLAN EXPIRE -> CREDITS -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. +- plain `list` has no `#*`: `PLAN EXPIRE -> AUTH EXPIRE -> CREDITS -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. - `list --live` has no `CREDITS`: `PLAN EXPIRE -> AUTH EXPIRE -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT -> #*`. - `switch` / `remove` support none of the credits, auth-expiry, or plan-expiry columns: `LAST -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. diff --git a/docs/table-layout.md b/docs/table-layout.md index d52eb30b..9177c0bc 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -35,14 +35,14 @@ label is truncated. Plain `list` at a wide width: ```text -ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST PLAN EXPIRE ------------------------------------------------------------------- -very-long. Pro - - 100% 58% - 2026-11-22 -work-main. Pro unlimited 3 31% 42% - 2026-12-01 +ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST PLAN EXPIRE AUTH EXPIRE +--------------------------------------------------------------------------------- +very-long. Pro - - 100% 58% - 2026-11-22 2027-01-15 08:00 +work-main. Pro unlimited 3 31% 42% - 2026-12-01 2026-12-31 23:59 ``` -Plain `list` at 70 columns hides the deferred `PLAN EXPIRE` column so status -and usage values stay readable: +Plain `list` at 70 columns hides the deferred `PLAN EXPIRE` and `AUTH EXPIRE` +columns so status and usage values stay readable: ```text ACCOUNT PLAN CR RC 5H WEEKLY LAST diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 52cbc009..88eccc0d 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -183,7 +183,7 @@ pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols return accountTable(bounded, prefix_width); } -pub const plain_columns = [_]ColumnId{ .account, .plan, .credits, .reset_credits, .rate_5h, .rate_week, .last, .plan_expire }; +pub const plain_columns = [_]ColumnId{ .account, .plan, .credits, .reset_credits, .rate_5h, .rate_week, .last, .plan_expire, .auth_expire }; pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .rate_5h, .rate_week, .last, .auth_expire, .reset_credits }; diff --git a/src/tui/table.zig b/src/tui/table.zig index dbe28686..34cc0488 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -137,8 +137,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( usage_overrides: ?[]const ?[]const u8, term_cols: ?usize, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "LAST ACTIVITY", "PLAN EXPIRE" }; - const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "CR", "RESET", "5H", "WEEKLY", "LAST", "PLAN EXP" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "LAST ACTIVITY", "PLAN EXPIRE", "AUTH EXPIRE" }; + const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "CR", "RESET", "5H", "WEEKLY", "LAST", "PLAN EXP", "AUTH EXP" }; var content_widths = [_]usize{ headers[0].len, headers[1].len, @@ -148,6 +148,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( headers[5].len, headers[6].len, headers[7].len, + headers[8].len, }; const now = std.Io.Timestamp.now(app_runtime.io(), .real).toSeconds(); var display = try display_rows.buildDisplayRows(std.heap.page_allocator, reg, null); @@ -177,6 +178,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(last_str); const plan_expiry_str = try cli_rows.formatFutureDateAlloc(std.heap.page_allocator, rec.plan_expires_at, now); defer std.heap.page_allocator.free(plan_expiry_str); + const auth_expiry_str = try cli_rows.formatTimestampAlloc(std.heap.page_allocator, rec.access_token_expires_at); + defer std.heap.page_allocator.free(auth_expiry_str); content_widths[1] = @max(content_widths[1], plan.len); content_widths[2] = @max(content_widths[2], credits_str.len); @@ -185,6 +188,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( content_widths[5] = @max(content_widths[5], rate_week_str.len); content_widths[6] = @max(content_widths[6], last_str.len); content_widths[7] = @max(content_widths[7], plan_expiry_str.len); + content_widths[8] = @max(content_widths[8], auth_expiry_str.len); } } @@ -262,6 +266,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(last); const plan_expiry = try cli_rows.formatFutureDateAlloc(std.heap.page_allocator, rec.plan_expires_at, now); defer std.heap.page_allocator.free(plan_expiry); + const auth_expiry = try cli_rows.formatTimestampAlloc(std.heap.page_allocator, rec.access_token_expires_at); + defer std.heap.page_allocator.free(auth_expiry); const indent: usize = @as(usize, row.depth) * 2; const indent_to_print: usize = @min(indent, widths[0]); const account_cell = try truncateAlloc(row.account_cell, widths[0] - indent_to_print); @@ -280,6 +286,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(last_cell); const plan_expiry_cell = try truncateAlloc(plan_expiry, widths[7]); defer std.heap.page_allocator.free(plan_expiry_cell); + const auth_expiry_cell = try truncateAlloc(auth_expiry, widths[8]); + defer std.heap.page_allocator.free(auth_expiry_cell); if (use_color) { if (row.is_active) { try out.writeAll(ansi.green); @@ -291,7 +299,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( try writeIndexPadded(out, selectable_counter + 1, idx_width); try out.writeAll(" "); try writeRepeat(out, ' ', indent_to_print); - const row_cells = [_][]const u8{ account_cell, plan_cell, credits_cell, reset_credits_cell, rate_5h_cell, rate_week_cell, last_cell, plan_expiry_cell }; + const row_cells = [_][]const u8{ account_cell, plan_cell, credits_cell, reset_credits_cell, rate_5h_cell, rate_week_cell, last_cell, plan_expiry_cell, auth_expiry_cell }; var row_first_visible = true; for (row_cells, 0..) |cell, index| { if (!visible[index]) continue; diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index b8720b7e..db8874cd 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -394,15 +394,20 @@ test "writeAccountsTable shows plan expiry for future data and hides expired dat try appendTestAccount(gpa, ®, "user-1::acc-1", "user@example.com", "", .plus); reg.accounts.items[0].plan_expires_at = now + 30 * 86400; + reg.accounts.items[0].access_token_expires_at = now + 60 * 86400; const expected = try codex_auth.cli.rows.formatFutureDateAlloc(gpa, reg.accounts.items[0].plan_expires_at, now); defer gpa.free(expected); + const expected_auth = try codex_auth.cli.rows.formatTimestampAlloc(gpa, reg.accounts.items[0].access_token_expires_at); + defer gpa.free(expected_auth); var buffer: [4096]u8 = undefined; var writer: std.Io.Writer = .fixed(&buffer); try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 200); var output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, "PLAN EXPIRE") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "AUTH EXPIRE") != null); try std.testing.expect(std.mem.indexOf(u8, output, expected) != null); + try std.testing.expect(std.mem.indexOf(u8, output, expected_auth) != null); const expired_at = now - 30 * 86400; const expired_text = try codex_auth.cli.rows.formatTimestampAlloc(gpa, expired_at); From 96e04a2950a741cfb32559587a6922460ef0f7f1 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 16:24:39 +0800 Subject: [PATCH 6/7] fix(table): align list and live column order Keep PLAN and PLAN EXPIRE adjacent in both views, move AUTH EXPIRE and RESET CREDITS before LAST ACTIVITY, and make LAST ACTIVITY the final and lowest-priority column. It is now deferred and hides first. Update the shared dynamic-column document and order/hide tests. --- docs/commands/list.md | 4 ++-- docs/dynamic-columns.md | 28 ++++++++++++++-------------- docs/table-layout.md | 18 +++++++++--------- src/cli/list_details.zig | 4 ++-- src/cli/table_layout.zig | 8 +++----- src/tui/table.zig | 34 +++++++++++++++++----------------- tests/cli_picker_test.zig | 10 ++++++---- tests/table_layout_test.zig | 12 +++++------- tests/tui_table_test.zig | 11 ++++++++++- 9 files changed, 68 insertions(+), 61 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 9c93bd0f..18f0e63d 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -17,8 +17,8 @@ codex-auth list --json - Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable. - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. -- Non-live output shows `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, `PLAN EXPIRE`, and `AUTH EXPIRE`. - The deferred `PLAN EXPIRE` and `AUTH EXPIRE` columns appear only when the terminal is wide enough. +- Non-live output shows `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `RESET CREDITS`, and `LAST ACTIVITY`. + The deferred `LAST ACTIVITY`, `PLAN EXPIRE`, and `AUTH EXPIRE` columns appear only when the terminal is wide enough. ## Refresh Modes diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md index e50465fb..248ee6a7 100644 --- a/docs/dynamic-columns.md +++ b/docs/dynamic-columns.md @@ -24,8 +24,8 @@ The order of each view is fixed. Unsupported columns are simply omitted. | View | Visual order | |---|---| -| `list` | `ACCOUNT`, `PLAN`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `LAST ACTIVITY`, `PLAN EXPIRE`, `AUTH EXPIRE` | -| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `5H`, `WEEKLY`, `LAST`, `AUTH EXPIRE`, `RESET CREDITS` | +| `list` | `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `RESET CREDITS`, `LAST ACTIVITY` | +| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `RESET CREDITS`, `LAST` | | `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | ## Shared Priority @@ -37,21 +37,21 @@ Every view uses the same ordered lists. A view with an unsupported column ```text #* -> ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) - -> RESET CREDITS (2) -> LAST (4) -> CREDITS (2) + -> RESET CREDITS (2) -> CREDITS (2) ``` -`PLAN EXPIRE` and `AUTH EXPIRE` are not in this list; they are deferred. +`PLAN EXPIRE`, `AUTH EXPIRE`, and `LAST` are not in this list; they are deferred. ### Grow order ```text -5H -> WEEKLY -> PLAN -> RESET CREDITS -> LAST -> CREDITS -> ACCOUNT +5H -> WEEKLY -> PLAN -> RESET CREDITS -> CREDITS -> ACCOUNT ``` ### Deferred order ```text -AUTH EXPIRE (10) -> PLAN EXPIRE (10) +AUTH EXPIRE (10) -> PLAN EXPIRE (10) -> LAST (4) ``` Deferred columns are considered only after every normal column has grown. Each @@ -64,10 +64,10 @@ From wide to narrow, columns disappear in the reverse priority order. The complete shared order is: ```text -PLAN EXPIRE +LAST +-> PLAN EXPIRE -> AUTH EXPIRE -> CREDITS --> LAST -> RESET CREDITS -> PLAN -> WEEKLY @@ -78,13 +78,13 @@ PLAN EXPIRE Unsupported columns are already absent from a view: -- plain `list` has no `#*`: `PLAN EXPIRE -> AUTH EXPIRE -> CREDITS -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. -- `list --live` has no `CREDITS`: `PLAN EXPIRE -> AUTH EXPIRE -> LAST -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT -> #*`. +- plain `list` has no `#*`: `LAST -> PLAN EXPIRE -> AUTH EXPIRE -> CREDITS -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. +- `list --live` has no `CREDITS`: `LAST -> PLAN EXPIRE -> AUTH EXPIRE -> RESET CREDITS -> PLAN -> WEEKLY -> 5H -> ACCOUNT -> #*`. - `switch` / `remove` support none of the credits, auth-expiry, or plan-expiry columns: `LAST -> PLAN -> WEEKLY -> 5H -> ACCOUNT`. -`CREDITS` was intentionally placed just above the expiry fields: it is a -low-priority metadata column. `RESET CREDITS`, which carries the actionable -reset count, stays above it. +`LAST` is the absolute lowest-priority column and hides first. `CREDITS` sits +just above the deferred expiry fields; `RESET CREDITS`, which carries the +actionable reset count, stays above it. ## Allocation Phases @@ -110,7 +110,7 @@ the requested terminal width. | `RESET CREDITS` | 2 | | `5H` | 4 | | `WEEKLY` | 6 | -| `LAST` | 4 | +| `LAST` | 4, deferred | | `AUTH EXPIRE` | 10, deferred | ## Headers diff --git a/docs/table-layout.md b/docs/table-layout.md index 9177c0bc..b6098e78 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -35,19 +35,19 @@ label is truncated. Plain `list` at a wide width: ```text -ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST PLAN EXPIRE AUTH EXPIRE ---------------------------------------------------------------------------------- -very-long. Pro - - 100% 58% - 2026-11-22 2027-01-15 08:00 -work-main. Pro unlimited 3 31% 42% - 2026-12-01 2026-12-31 23:59 +ACCOUNT PLAN PLAN EXPIRE CREDITS RC 5H WEEKLY AUTH EXPIRE RESET CREDITS LAST ACTIVITY +------------------------------------------------------------------------------------------------------------------ +very-long. Pro 2026-11-22 - - 100% 58% 2027-01-15 08:00 - 3d ago +work-main. Pro 2026-12-01 unlimited 3 31% 42% 2026-12-31 23:59 0 Now ``` -Plain `list` at 70 columns hides the deferred `PLAN EXPIRE` and `AUTH EXPIRE` -columns so status and usage values stay readable: +Plain `list` at 70 columns hides the deferred `LAST ACTIVITY`, `PLAN EXPIRE`, +and `AUTH EXPIRE` columns so status and usage values stay readable: ```text -ACCOUNT PLAN CR RC 5H WEEKLY LAST --------------------------------------------------- -very-long. Pro - - 100% 58% - +ACCOUNT PLAN CR RC 5H WEEKLY +-------------------------------------------- +very-long. Pro - - 100% 58% ``` `list --live` at a medium width: diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index d06118aa..a7bbcf06 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -28,9 +28,9 @@ pub fn computeWidths( table_layout.columnSpec(.plan_expire, @max("PLAN EXPIRE".len, 10)), table_layout.columnSpec(.rate_5h, @max(widths.rate_5h, "5H".len)), table_layout.columnSpec(.rate_week, @max(widths.rate_week, "WEEKLY".len)), - table_layout.columnSpec(.last, @max(widths.last, "LAST".len)), table_layout.columnSpec(.auth_expire, @max("AUTH EXPIRE".len, 16)), table_layout.columnSpec(.reset_credits, @max("RESET CREDITS".len, 14)), + table_layout.columnSpec(.last, @max(widths.last, "LAST".len)), }; var bounded = [_]usize{0} ** column_count; table_layout.allocateColumns(.{ @@ -80,9 +80,9 @@ pub fn write( plan_expiry, row.rate_5h, row.rate_week, - row.last, token_expiry, reset_full, + row.last, }; const row_style = if (row.number.len != 0 and row.number[0] == '!') style.role.error_text else if (row.number.len != 0 and row.number[0] == '*') style.role.success else ""; try writer.writeStyle(row_style); diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index 88eccc0d..f4acd341 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -151,7 +151,6 @@ pub const shared_first_order = [_]ColumnId{ .rate_week, .plan, .reset_credits, - .last, .credits, }; pub const shared_grow_order = [_]ColumnId{ @@ -159,11 +158,10 @@ pub const shared_grow_order = [_]ColumnId{ .rate_week, .plan, .reset_credits, - .last, .credits, .account, }; -pub const shared_deferred_order = [_]ColumnId{ .auth_expire, .plan_expire }; +pub const shared_deferred_order = [_]ColumnId{ .auth_expire, .plan_expire, .last }; pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols: ?usize) LiveTable { const source_widths = [_]usize{ widths.email, widths.plan, widths.rate_5h, widths.rate_week, widths.last }; @@ -183,9 +181,9 @@ pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols return accountTable(bounded, prefix_width); } -pub const plain_columns = [_]ColumnId{ .account, .plan, .credits, .reset_credits, .rate_5h, .rate_week, .last, .plan_expire, .auth_expire }; +pub const plain_columns = [_]ColumnId{ .account, .plan, .plan_expire, .credits, .rate_5h, .rate_week, .auth_expire, .reset_credits, .last }; -pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .rate_5h, .rate_week, .last, .auth_expire, .reset_credits }; +pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .rate_5h, .rate_week, .auth_expire, .reset_credits, .last }; pub fn defaultTargetWidth(id: ColumnId) usize { return switch (id) { diff --git a/src/tui/table.zig b/src/tui/table.zig index 34cc0488..2fbcae8d 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -137,8 +137,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( usage_overrides: ?[]const ?[]const u8, term_cols: ?usize, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "LAST ACTIVITY", "PLAN EXPIRE", "AUTH EXPIRE" }; - const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "CR", "RESET", "5H", "WEEKLY", "LAST", "PLAN EXP", "AUTH EXP" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXPIRE", "CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "RESET CREDITS", "LAST ACTIVITY" }; + const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXP", "CR", "5H", "WEEKLY", "AUTH EXP", "RESET", "LAST" }; var content_widths = [_]usize{ headers[0].len, headers[1].len, @@ -182,13 +182,13 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(auth_expiry_str); content_widths[1] = @max(content_widths[1], plan.len); - content_widths[2] = @max(content_widths[2], credits_str.len); - content_widths[3] = @max(content_widths[3], reset_credits_str.len); + content_widths[2] = @max(content_widths[2], plan_expiry_str.len); + content_widths[3] = @max(content_widths[3], credits_str.len); content_widths[4] = @max(content_widths[4], rate_5h_str.len); content_widths[5] = @max(content_widths[5], rate_week_str.len); - content_widths[6] = @max(content_widths[6], last_str.len); - content_widths[7] = @max(content_widths[7], plan_expiry_str.len); - content_widths[8] = @max(content_widths[8], auth_expiry_str.len); + content_widths[6] = @max(content_widths[6], auth_expiry_str.len); + content_widths[7] = @max(content_widths[7], reset_credits_str.len); + content_widths[8] = @max(content_widths[8], last_str.len); } } @@ -213,7 +213,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( const fallback = compact_headers[index]; header_cells[index] = if (width >= full.len) full - else if (index == 3 and width < "RESET".len) + else if (index == 7 and width < "RESET".len) if (width >= "RC".len) "RC" else "R" else if (width >= fallback.len) fallback @@ -274,20 +274,20 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(account_cell); const plan_cell = try truncateAlloc(plan, widths[1]); defer std.heap.page_allocator.free(plan_cell); - const credits_cell = try truncateAlloc(credits_str, widths[2]); + const plan_expiry_cell = try truncateAlloc(plan_expiry, widths[2]); + defer std.heap.page_allocator.free(plan_expiry_cell); + const credits_cell = try truncateAlloc(credits_str, widths[3]); defer std.heap.page_allocator.free(credits_cell); - const reset_credits_cell = try truncateAlloc(reset_credits_str, widths[3]); - defer std.heap.page_allocator.free(reset_credits_cell); const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[4]); defer std.heap.page_allocator.free(rate_5h_cell); const rate_week_cell = try truncateAlloc(rate_week_str, widths[5]); defer std.heap.page_allocator.free(rate_week_cell); - const last_cell = try truncateAlloc(last, widths[6]); - defer std.heap.page_allocator.free(last_cell); - const plan_expiry_cell = try truncateAlloc(plan_expiry, widths[7]); - defer std.heap.page_allocator.free(plan_expiry_cell); - const auth_expiry_cell = try truncateAlloc(auth_expiry, widths[8]); + const auth_expiry_cell = try truncateAlloc(auth_expiry, widths[6]); defer std.heap.page_allocator.free(auth_expiry_cell); + const reset_credits_cell = try truncateAlloc(reset_credits_str, widths[7]); + defer std.heap.page_allocator.free(reset_credits_cell); + const last_cell = try truncateAlloc(last, widths[8]); + defer std.heap.page_allocator.free(last_cell); if (use_color) { if (row.is_active) { try out.writeAll(ansi.green); @@ -299,7 +299,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( try writeIndexPadded(out, selectable_counter + 1, idx_width); try out.writeAll(" "); try writeRepeat(out, ' ', indent_to_print); - const row_cells = [_][]const u8{ account_cell, plan_cell, credits_cell, reset_credits_cell, rate_5h_cell, rate_week_cell, last_cell, plan_expiry_cell, auth_expiry_cell }; + const row_cells = [_][]const u8{ account_cell, plan_cell, plan_expiry_cell, credits_cell, rate_5h_cell, rate_week_cell, auth_expiry_cell, reset_credits_cell, last_cell }; var row_first_visible = true; for (row_cells, 0..) |cell, index| { if (!visible[index]) continue; diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 33b3f101..751bf44d 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1807,10 +1807,12 @@ test "live list defers PLAN EXPIRE and keeps the configured column order" { try cli.render.renderListBody(gpa, &wide_writer, ®, rows.items, 2, rows.widths, 200); const body = wide_body.written(); try std.testing.expect(std.mem.indexOf(u8, body, "PLAN EXPIRE") != null); - const plan = std.mem.indexOf(u8, body, "PLAN") orelse return error.TestUnexpectedResult; - const expire = std.mem.indexOf(u8, body, "PLAN EXPIRE") orelse return error.TestUnexpectedResult; - const five_hour = std.mem.indexOf(u8, body, "5H") orelse return error.TestUnexpectedResult; - try std.testing.expect(plan < expire and expire < five_hour); + var last_index: usize = 0; + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "5H", "WEEKLY", "AUTH EXPIRE", "RESET CREDITS", "LAST" }) |token| { + const index = std.mem.indexOf(u8, body, token) orelse return error.TestUnexpectedResult; + try std.testing.expect(index >= last_index); + last_index = index; + } lines = std.mem.splitScalar(u8, body, '\n'); while (lines.next()) |line| try std.testing.expect(line.len <= 200); } diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index f1051e5e..0b997d90 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -117,11 +117,11 @@ test "Scenario: Given remaining table width when rendering then status plan and const output = writer.buffered(); try expectLinesWithin(output, 50); - try std.testing.expect(std.mem.indexOf(u8, output, "very-l.mple.com") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "very-l.me@example.com") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Business") != null); try std.testing.expect(std.mem.indexOf(u8, output, "100%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "42%") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "Now") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Now") == null); try std.testing.expect(std.mem.indexOf(u8, output, "very-long-account-name@example.com") == null); } @@ -231,7 +231,6 @@ test "Scenario: Given a FileNotFound override and a long account when rendering const output = writer.buffered(); try expectLinesWithin(output, 70); try std.testing.expect(std.mem.indexOf(u8, output, "FileNotFound") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "Fil.") == null); } test "Scenario: Given compact list widths when rendering then headers use CR, RC and RESET fallbacks" { @@ -285,7 +284,7 @@ test "Scenario: Given unlimited credits when rendering at 120 columns then the v try std.testing.expect(std.mem.indexOf(u8, output, "u.") == null); } -test "Scenario: Given low-priority metadata when narrowing then CREDITS hides before LAST" { +test "Scenario: Given low-priority metadata when narrowing then LAST hides before CREDITS" { const gpa = std.testing.allocator; var reg = makeTestRegistry(); defer reg.deinit(gpa); @@ -301,7 +300,6 @@ test "Scenario: Given low-priority metadata when narrowing then CREDITS hides be try table_format.writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, null, 48); const output = writer.buffered(); try expectLinesWithin(output, 48); - try std.testing.expect(std.mem.indexOf(u8, output, "LAST") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "CREDITS") == null); - try std.testing.expect(std.mem.indexOf(u8, output, "CR") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "LAST") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "CR") != null); } diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index db8874cd..1a53d5a0 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -176,7 +176,8 @@ test "writeAccountsTable shows integer credits balance column" { const output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, "CREDITS") != null); try std.testing.expect(std.mem.indexOf(u8, output, "RESET CREDITS") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "Plus 5") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "Plus") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "5") != null); try std.testing.expect(std.mem.indexOf(u8, output, "5.39") == null); try std.testing.expect(std.mem.indexOf(u8, output, "2") != null); } @@ -408,6 +409,12 @@ test "writeAccountsTable shows plan expiry for future data and hides expired dat try std.testing.expect(std.mem.indexOf(u8, output, "AUTH EXPIRE") != null); try std.testing.expect(std.mem.indexOf(u8, output, expected) != null); try std.testing.expect(std.mem.indexOf(u8, output, expected_auth) != null); + var last_index: usize = 0; + for ([_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXPIRE", "CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "RESET CREDITS", "LAST ACTIVITY" }) |token| { + const index = std.mem.indexOf(u8, output, token) orelse return error.TestUnexpectedResult; + try std.testing.expect(index >= last_index); + last_index = index; + } const expired_at = now - 30 * 86400; const expired_text = try codex_auth.cli.rows.formatTimestampAlloc(gpa, expired_at); @@ -444,6 +451,8 @@ test "writeAccountsTable defers PLAN EXPIRE on narrow widths" { try writeAccountsTableWithUsageOverridesForCols(&writer, ®, false, &overrides, 70); var output = writer.buffered(); try std.testing.expect(std.mem.indexOf(u8, output, "PLAN EXPIRE") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "AUTH EXPIRE") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "LAST ACTIVITY") == null); var lines = std.mem.splitScalar(u8, output, '\n'); while (lines.next()) |line| try std.testing.expect(line.len <= 70); From 97352e402eeeac13dce4e0b75e3c7a6bc28f84f3 Mon Sep 17 00:00:00 2001 From: Loongphy Date: Sun, 25 Oct 2026 16:57:05 +0800 Subject: [PATCH 7/7] fix(table): finalize column order and account growth Grow ACCOUNT only after deferred columns, so long labels cannot hide expiry or last-activity fields. Render zero reset credits as 0 without an expiry marker. Keep CREDITS and RESET CREDITS adjacent, LAST ACTIVITY at the end, and update the dynamic-column docs and regression tests. --- docs/commands/list.md | 4 ++-- docs/dynamic-columns.md | 10 +++++++--- docs/table-layout.md | 8 ++++---- src/cli/list_details.zig | 5 +++-- src/cli/table_layout.zig | 16 ++++++++++++---- src/tui/table.zig | 30 +++++++++++++++--------------- tests/cli_picker_test.zig | 31 ++++++++++++++++++++++++++++--- tests/table_layout_test.zig | 4 ++-- tests/tui_table_test.zig | 2 +- 9 files changed, 74 insertions(+), 36 deletions(-) diff --git a/docs/commands/list.md b/docs/commands/list.md index 18f0e63d..a78a084d 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -17,7 +17,7 @@ codex-auth list --json - Syncs the current `auth.json` into the registry before rendering when the current auth file is parseable. - Shows selectable row numbers using the same ordering as `switch` and `remove`. - Groups rows by email when the same email owns multiple account snapshots. -- Non-live output shows `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `RESET CREDITS`, and `LAST ACTIVITY`. +- Non-live output shows `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, and `LAST ACTIVITY`. The deferred `LAST ACTIVITY`, `PLAN EXPIRE`, and `AUTH EXPIRE` columns appear only when the terminal is wide enough. ## Refresh Modes @@ -52,7 +52,7 @@ When local-only refresh is active, only the active account can be updated from l - In non-live output, `CREDITS` shows the integer part of the current `credits.balance` value. - `PLAN EXPIRE` renders the local date from the stored `id_token` subscription claim. Missing or past timestamps render as `-` in both plain and live tables. -- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. +- `RESET CREDITS` remains a separate field that shows the stored reset-credit count when remote usage refresh provides it. A zero count renders as `0`; a non-zero count with no known expiry renders as `N (-)`. - JSON output includes the raw `plan_expires_at` timestamp and the returned reset-credit details. The terminal column hides past plan-expiry timestamps, but the JSON value remains the stored timestamp so callers can detect stale snapshots. diff --git a/docs/dynamic-columns.md b/docs/dynamic-columns.md index 248ee6a7..b272f035 100644 --- a/docs/dynamic-columns.md +++ b/docs/dynamic-columns.md @@ -24,8 +24,8 @@ The order of each view is fixed. Unsupported columns are simply omitted. | View | Visual order | |---|---| -| `list` | `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `RESET CREDITS`, `LAST ACTIVITY` | -| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `RESET CREDITS`, `LAST` | +| `list` | `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `CREDITS`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST ACTIVITY` | +| `list --live` | `#*`, `ACCOUNT`, `PLAN`, `PLAN EXPIRE`, `RESET CREDITS`, `5H`, `WEEKLY`, `AUTH EXPIRE`, `LAST` | | `switch`, `remove` | `ACCOUNT`, `PLAN`, `5H`, `WEEKLY`, `LAST` | ## Shared Priority @@ -45,7 +45,7 @@ Every view uses the same ordered lists. A view with an unsupported column ### Grow order ```text -5H -> WEEKLY -> PLAN -> RESET CREDITS -> CREDITS -> ACCOUNT +5H -> WEEKLY -> PLAN -> RESET CREDITS -> CREDITS ``` ### Deferred order @@ -58,6 +58,10 @@ Deferred columns are considered only after every normal column has grown. Each still needs its target width and separator to fit, so deferred columns never become one-column dot cells. +`ACCOUNT` is expanded last, after the deferred pass, using only leftover width. +A long account label therefore cannot hide `AUTH EXPIRE`, `PLAN EXPIRE`, or +`LAST ACTIVITY`. + ## Shared Hide Order From wide to narrow, columns disappear in the reverse priority order. The diff --git a/docs/table-layout.md b/docs/table-layout.md index b6098e78..9945645f 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -35,10 +35,10 @@ label is truncated. Plain `list` at a wide width: ```text -ACCOUNT PLAN PLAN EXPIRE CREDITS RC 5H WEEKLY AUTH EXPIRE RESET CREDITS LAST ACTIVITY ------------------------------------------------------------------------------------------------------------------- -very-long. Pro 2026-11-22 - - 100% 58% 2027-01-15 08:00 - 3d ago -work-main. Pro 2026-12-01 unlimited 3 31% 42% 2026-12-31 23:59 0 Now +ACCOUNT PLAN PLAN EXPIRE CREDITS RESET CREDITS 5H WEEKLY AUTH EXPIRE LAST ACTIVITY +--------------------------------------------------------------------------------------------------------------------------- +very-long-account. Pro 2026-11-22 unlimited 3 100% 58% 2027-01-15 08:00 Now +work-main. Business - 12 0 31% 42% 2026-12-31 23:59 3d ago ``` Plain `list` at 70 columns hides the deferred `LAST ACTIVITY`, `PLAN EXPIRE`, diff --git a/src/cli/list_details.zig b/src/cli/list_details.zig index a7bbcf06..6995516f 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -26,10 +26,10 @@ pub fn computeWidths( table_layout.columnSpec(.account, @max(widths.email, "ACCOUNT".len)), table_layout.columnSpec(.plan, @max(widths.plan, "PLAN".len)), table_layout.columnSpec(.plan_expire, @max("PLAN EXPIRE".len, 10)), + table_layout.columnSpec(.reset_credits, @max("RESET CREDITS".len, 14)), table_layout.columnSpec(.rate_5h, @max(widths.rate_5h, "5H".len)), table_layout.columnSpec(.rate_week, @max(widths.rate_week, "WEEKLY".len)), table_layout.columnSpec(.auth_expire, @max("AUTH EXPIRE".len, 16)), - table_layout.columnSpec(.reset_credits, @max("RESET CREDITS".len, 14)), table_layout.columnSpec(.last, @max(widths.last, "LAST".len)), }; var bounded = [_]usize{0} ** column_count; @@ -78,10 +78,10 @@ pub fn write( row.account, row.plan, plan_expiry, + reset_full, row.rate_5h, row.rate_week, token_expiry, - reset_full, row.last, }; const row_style = if (row.number.len != 0 and row.number[0] == '!') style.role.error_text else if (row.number.len != 0 and row.number[0] == '*') style.role.success else ""; @@ -163,6 +163,7 @@ fn resetCreditsCell(allocator: std.mem.Allocator, account: registry.AccountRecor } } const value = count orelse return std.fmt.allocPrint(allocator, "-", .{}); + if (value == 0) return std.fmt.allocPrint(allocator, "0", .{}); var earliest: ?i64 = null; if (usage.reset_credit) |cards| { for (cards) |card| { diff --git a/src/cli/table_layout.zig b/src/cli/table_layout.zig index f4acd341..173bf440 100644 --- a/src/cli/table_layout.zig +++ b/src/cli/table_layout.zig @@ -159,7 +159,6 @@ pub const shared_grow_order = [_]ColumnId{ .plan, .reset_credits, .credits, - .account, }; pub const shared_deferred_order = [_]ColumnId{ .auth_expire, .plan_expire, .last }; @@ -181,9 +180,9 @@ pub fn accountTableForSwitch(widths: SwitchWidths, prefix_width: usize, max_cols return accountTable(bounded, prefix_width); } -pub const plain_columns = [_]ColumnId{ .account, .plan, .plan_expire, .credits, .rate_5h, .rate_week, .auth_expire, .reset_credits, .last }; +pub const plain_columns = [_]ColumnId{ .account, .plan, .plan_expire, .credits, .reset_credits, .rate_5h, .rate_week, .auth_expire, .last }; -pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .rate_5h, .rate_week, .auth_expire, .reset_credits, .last }; +pub const list_detail_columns = [_]ColumnId{ .number, .account, .plan, .plan_expire, .reset_credits, .rate_5h, .rate_week, .auth_expire, .last }; pub fn defaultTargetWidth(id: ColumnId) usize { return switch (id) { @@ -269,12 +268,21 @@ pub fn allocateColumns(layout: ColumnLayout, max_cols: ?usize, widths: []usize) const target = @min(column.content_width, column.target_width); if (target == 0) continue; const separator = if (visible_count == 0) 0 else layout.separator_width; - if (remaining < target + separator) continue; + if (remaining < target + separator) break; widths[index] = target; remaining -= target + separator; visible_count += 1; growBoundedWidth(&remaining, &widths[index], column.content_width); } + + // ACCOUNT identity is useful but approximate: give every other column, + // including deferred columns, a chance before expanding it. A long account + // label therefore cannot squeeze AUTH/PLAN expiry or LAST out of the table. + if (columnIndex(layout.columns, .account)) |index| { + if (widths[index] != 0) { + growBoundedWidth(&remaining, &widths[index], layout.columns[index].content_width); + } + } } fn columnIndex(columns: []const ColumnSpec, id: ColumnId) ?usize { diff --git a/src/tui/table.zig b/src/tui/table.zig index 2fbcae8d..7ac2a83b 100644 --- a/src/tui/table.zig +++ b/src/tui/table.zig @@ -137,8 +137,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( usage_overrides: ?[]const ?[]const u8, term_cols: ?usize, ) !void { - const headers = [_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXPIRE", "CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "RESET CREDITS", "LAST ACTIVITY" }; - const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXP", "CR", "5H", "WEEKLY", "AUTH EXP", "RESET", "LAST" }; + const headers = [_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXPIRE", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "LAST ACTIVITY" }; + const compact_headers = [_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXP", "CR", "RESET", "5H", "WEEKLY", "AUTH EXP", "LAST" }; var content_widths = [_]usize{ headers[0].len, headers[1].len, @@ -184,10 +184,10 @@ pub fn writeAccountsTableWithUsageOverridesForCols( content_widths[1] = @max(content_widths[1], plan.len); content_widths[2] = @max(content_widths[2], plan_expiry_str.len); content_widths[3] = @max(content_widths[3], credits_str.len); - content_widths[4] = @max(content_widths[4], rate_5h_str.len); - content_widths[5] = @max(content_widths[5], rate_week_str.len); - content_widths[6] = @max(content_widths[6], auth_expiry_str.len); - content_widths[7] = @max(content_widths[7], reset_credits_str.len); + content_widths[4] = @max(content_widths[4], reset_credits_str.len); + content_widths[5] = @max(content_widths[5], rate_5h_str.len); + content_widths[6] = @max(content_widths[6], rate_week_str.len); + content_widths[7] = @max(content_widths[7], auth_expiry_str.len); content_widths[8] = @max(content_widths[8], last_str.len); } } @@ -213,7 +213,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( const fallback = compact_headers[index]; header_cells[index] = if (width >= full.len) full - else if (index == 7 and width < "RESET".len) + else if (index == 4 and width < "RESET".len) if (width >= "RC".len) "RC" else "R" else if (width >= fallback.len) fallback @@ -258,9 +258,9 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(credits_str); const reset_credits_str = try resetCreditsCellAlloc(std.heap.page_allocator, rec.last_usage, usage_override); defer std.heap.page_allocator.free(reset_credits_str); - const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[4], usage_override); + const rate_5h_str = try usageCellTextAlloc(std.heap.page_allocator, rate_5h, widths[5], usage_override); defer std.heap.page_allocator.free(rate_5h_str); - const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[5], usage_override); + const rate_week_str = try usageCellTextAlloc(std.heap.page_allocator, rate_week, widths[6], usage_override); defer std.heap.page_allocator.free(rate_week_str); const last = try timefmt.formatRelativeTimeOrDashAlloc(std.heap.page_allocator, rec.last_usage_at, now); defer std.heap.page_allocator.free(last); @@ -278,14 +278,14 @@ pub fn writeAccountsTableWithUsageOverridesForCols( defer std.heap.page_allocator.free(plan_expiry_cell); const credits_cell = try truncateAlloc(credits_str, widths[3]); defer std.heap.page_allocator.free(credits_cell); - const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[4]); + const reset_credits_cell = try truncateAlloc(reset_credits_str, widths[4]); + defer std.heap.page_allocator.free(reset_credits_cell); + const rate_5h_cell = try truncateAlloc(rate_5h_str, widths[5]); defer std.heap.page_allocator.free(rate_5h_cell); - const rate_week_cell = try truncateAlloc(rate_week_str, widths[5]); + const rate_week_cell = try truncateAlloc(rate_week_str, widths[6]); defer std.heap.page_allocator.free(rate_week_cell); - const auth_expiry_cell = try truncateAlloc(auth_expiry, widths[6]); + const auth_expiry_cell = try truncateAlloc(auth_expiry, widths[7]); defer std.heap.page_allocator.free(auth_expiry_cell); - const reset_credits_cell = try truncateAlloc(reset_credits_str, widths[7]); - defer std.heap.page_allocator.free(reset_credits_cell); const last_cell = try truncateAlloc(last, widths[8]); defer std.heap.page_allocator.free(last_cell); if (use_color) { @@ -299,7 +299,7 @@ pub fn writeAccountsTableWithUsageOverridesForCols( try writeIndexPadded(out, selectable_counter + 1, idx_width); try out.writeAll(" "); try writeRepeat(out, ' ', indent_to_print); - const row_cells = [_][]const u8{ account_cell, plan_cell, plan_expiry_cell, credits_cell, rate_5h_cell, rate_week_cell, auth_expiry_cell, reset_credits_cell, last_cell }; + const row_cells = [_][]const u8{ account_cell, plan_cell, plan_expiry_cell, credits_cell, reset_credits_cell, rate_5h_cell, rate_week_cell, auth_expiry_cell, last_cell }; var row_first_visible = true; for (row_cells, 0..) |cell, index| { if (!visible[index]) continue; diff --git a/tests/cli_picker_test.zig b/tests/cli_picker_test.zig index 751bf44d..d751f0d2 100644 --- a/tests/cli_picker_test.zig +++ b/tests/cli_picker_test.zig @@ -1796,10 +1796,10 @@ test "live list defers PLAN EXPIRE and keeps the configured column order" { var narrow_body: std.Io.Writer.Allocating = .init(gpa); defer narrow_body.deinit(); var narrow_writer = StyledWriter.init(&narrow_body.writer, false); - try cli.render.renderListBody(gpa, &narrow_writer, ®, rows.items, 2, rows.widths, 120); + try cli.render.renderListBody(gpa, &narrow_writer, ®, rows.items, 2, rows.widths, 90); try std.testing.expect(std.mem.indexOf(u8, narrow_body.written(), "PLAN EXPIRE") == null); var lines = std.mem.splitScalar(u8, narrow_body.written(), '\n'); - while (lines.next()) |line| try std.testing.expect(line.len <= 120); + while (lines.next()) |line| try std.testing.expect(line.len <= 90); var wide_body: std.Io.Writer.Allocating = .init(gpa); defer wide_body.deinit(); @@ -1808,7 +1808,7 @@ test "live list defers PLAN EXPIRE and keeps the configured column order" { const body = wide_body.written(); try std.testing.expect(std.mem.indexOf(u8, body, "PLAN EXPIRE") != null); var last_index: usize = 0; - for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "5H", "WEEKLY", "AUTH EXPIRE", "RESET CREDITS", "LAST" }) |token| { + for ([_][]const u8{ "#*", "ACCOUNT", "PLAN", "PLAN EXPIRE", "RESET CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "LAST" }) |token| { const index = std.mem.indexOf(u8, body, token) orelse return error.TestUnexpectedResult; try std.testing.expect(index >= last_index); last_index = index; @@ -1816,3 +1816,28 @@ test "live list defers PLAN EXPIRE and keeps the configured column order" { lines = std.mem.splitScalar(u8, body, '\n'); while (lines.next()) |line| try std.testing.expect(line.len <= 200); } + +test "live list renders zero reset credits without an expiry marker" { + const gpa = std.testing.allocator; + var reg = makeTestRegistry(); + defer reg.deinit(gpa); + try appendTestAccount(gpa, ®, "user::account", "test@example.com", "", .pro); + reg.accounts.items[0].last_usage = .{ + .primary = null, + .secondary = null, + .credits = null, + .plan_type = .pro, + .reset_credits = 0, + .reset_credit = try codex_auth.api.usage.parseResetCreditResponse(gpa, + \\{"available_count":0,"credits":[]} + ), + }; + 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, null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "0 (-)") == null); + try std.testing.expect(std.mem.indexOf(u8, body.written(), "0") != null); +} diff --git a/tests/table_layout_test.zig b/tests/table_layout_test.zig index 0b997d90..eeb6226e 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -117,11 +117,11 @@ test "Scenario: Given remaining table width when rendering then status plan and const output = writer.buffered(); try expectLinesWithin(output, 50); - try std.testing.expect(std.mem.indexOf(u8, output, "very-l.me@example.com") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "very-l.mple.com") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Business") != null); try std.testing.expect(std.mem.indexOf(u8, output, "100%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "42%") != null); - try std.testing.expect(std.mem.indexOf(u8, output, "Now") == null); + try std.testing.expect(std.mem.indexOf(u8, output, "Now") != null); try std.testing.expect(std.mem.indexOf(u8, output, "very-long-account-name@example.com") == null); } diff --git a/tests/tui_table_test.zig b/tests/tui_table_test.zig index 1a53d5a0..53bc9673 100644 --- a/tests/tui_table_test.zig +++ b/tests/tui_table_test.zig @@ -410,7 +410,7 @@ test "writeAccountsTable shows plan expiry for future data and hides expired dat try std.testing.expect(std.mem.indexOf(u8, output, expected) != null); try std.testing.expect(std.mem.indexOf(u8, output, expected_auth) != null); var last_index: usize = 0; - for ([_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXPIRE", "CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "RESET CREDITS", "LAST ACTIVITY" }) |token| { + for ([_][]const u8{ "ACCOUNT", "PLAN", "PLAN EXPIRE", "CREDITS", "RESET CREDITS", "5H", "WEEKLY", "AUTH EXPIRE", "LAST ACTIVITY" }) |token| { const index = std.mem.indexOf(u8, output, token) orelse return error.TestUnexpectedResult; try std.testing.expect(index >= last_index); last_index = index;