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/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..a78a084d 100644 --- a/docs/commands/list.md +++ b/docs/commands/list.md @@ -17,7 +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`. +- 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 @@ -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. +- 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 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. -- `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. +- `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. 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. - 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/dynamic-columns.md b/docs/dynamic-columns.md new file mode 100644 index 00000000..b272f035 --- /dev/null +++ b/docs/dynamic-columns.md @@ -0,0 +1,145 @@ +# 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 and hide order. + +## Model + +Each view supplies: + +- `columns`: the visual order and content width of its supported columns; +- `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. + +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. Unsupported columns are simply omitted. + +| View | Visual order | +|---|---| +| `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 + +Every view uses the same ordered lists. A view with an unsupported column +(content width `0`) skips that entry without changing the order. + +### Reserve order (highest to lowest) + +```text +#* -> ACCOUNT (10) -> 5H (4) -> WEEKLY (6) -> PLAN (4) + -> RESET CREDITS (2) -> CREDITS (2) +``` + +`PLAN EXPIRE`, `AUTH EXPIRE`, and `LAST` are not in this list; they are deferred. + +### Grow order + +```text +5H -> WEEKLY -> PLAN -> RESET CREDITS -> CREDITS +``` + +### Deferred order + +```text +AUTH EXPIRE (10) -> PLAN EXPIRE (10) -> LAST (4) +``` + +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 +complete shared order is: + +```text +LAST +-> PLAN EXPIRE +-> AUTH EXPIRE +-> CREDITS +-> RESET CREDITS +-> PLAN +-> WEEKLY +-> 5H +-> ACCOUNT +-> #* +``` + +Unsupported columns are already absent from a view: + +- 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`. + +`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 + +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, deferred | +| `AUTH EXPIRE` | 10, deferred | + +## 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. 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%`). + +## Related + +- Account-label truncation: [table-layout.md](table-layout.md) +- `list` column contents: [commands/list.md](commands/list.md) diff --git a/docs/implement.md b/docs/implement.md index 00566462..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 @@ -76,6 +77,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 +89,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 +127,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/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) diff --git a/docs/table-layout.md b/docs/table-layout.md index b03806eb..9945645f 100644 --- a/docs/table-layout.md +++ b/docs/table-layout.md @@ -1,68 +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` - -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. - -## 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) -``` - -- 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 @@ -92,20 +32,27 @@ label is truncated. ## Examples -Plain `list`, where all seven columns are enabled: +Plain `list` at a wide width: + +```text +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`, +and `AUTH EXPIRE` columns so status and usage values stay readable: ```text -ACCOUNT PLAN CREDITS RC 5H WEEKLY LAST --------------------------------------------------------- -very-long. Pro - - 100% 58% - -work-main. Pro unlimited 3 31% 42% - +ACCOUNT PLAN CR RC 5H WEEKLY +-------------------------------------------- +very-long. Pro - - 100% 58% ``` -Live view, where `CREDITS` and `RESET CREDITS` 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/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..6995516f 100644 --- a/src/cli/list_details.zig +++ b/src/cli/list_details.zig @@ -3,28 +3,58 @@ 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", "5H", "WEEKLY", "LAST", "AUTH EXPIRE", "RESET CREDITS" }; -const base_desired_widths = [_]usize{ 2, 18, 8, 12, 12, 10, 16, 14 }; -const base_minimum_widths = [_]usize{ 1, 8, 4, 4, 6, 5, 10, 8 }; -const base_column_count = base_headers.len; const available_status = "available"; const separator_width: usize = 3; +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(.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(.last, @max(widths.last, "LAST".len)), + }; + var bounded = [_]usize{0} ** column_count; + table_layout.allocateColumns(.{ + .columns = &columns, + .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); + 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( @@ -32,30 +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 reset_credits_cell = try resetCreditsCell(allocator, account); - defer allocator.free(reset_credits_cell); + const plan_expiry = try rows.formatFutureDateAlloc(allocator, account.plan_expires_at, now); + defer allocator.free(plan_expiry); + const reset_full = try resetCreditsCell(allocator, account); + defer allocator.free(reset_full); + const reset_short = shortResetCell(reset_full); - const number = row.number; - var values: [base_column_count][]const u8 = undefined; - var widths: [base_column_count]usize = undefined; - var minimums: [base_column_count]usize = undefined; - var count: usize = 0; - const base_values = [_][]const u8{ number, row.account, row.plan, row.rate_5h, row.rate_week, row.last, token_expiry, reset_credits_cell }; - for (base_values, 0..) |value, index| { - if (!visible_base[index]) continue; - values[count] = value; - widths[count] = if (index == 0) number_width else base_desired_widths[index]; - minimums[count] = if (index == 0) number_width else base_minimum_widths[index]; - count += 1; + const values = [_][]const u8{ + row.number, + row.account, + row.plan, + plan_expiry, + reset_full, + row.rate_5h, + row.rate_week, + token_expiry, + 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); + 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]), + } } - 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 writer.reset(); + try writer.out.writeByte('\n'); +} + +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; + } + } + 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 @@ -75,11 +163,12 @@ 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| { 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; } } @@ -89,139 +178,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{ 7, 6, 5, 2 }; - while (minimumTableWidth(visible, number_width) > limit) { - var changed = false; - for (drop_order) |index| { - if (visible[index]) { - visible[index] = false; - changed = true; - break; - } - } - if (!changed) break; - } - return visible; -} - -fn minimumTableWidth(visible: [base_column_count]bool, number_width: usize) usize { - var total: usize = 0; - var column_count: usize = 0; - for (base_minimum_widths, 0..) |width, index| { - if (!visible[index]) continue; - total += if (index == 0) @max(width, number_width) else width; - column_count += 1; - } - return if (column_count == 0) 0 else total + (column_count - 1) * separator_width; -} - -fn writeAlignedRow( - writer: *style.StyledWriter, - values: []const []const u8, - desired: []const usize, - minimums: []const usize, - max_cols: ?usize, - account_column: bool, - row_style: []const u8, - header: bool, -) !void { - var widths: [base_column_count]usize = undefined; - for (desired, 0..) |width, index| widths[index] = width; - if (max_cols) |limit| { - var total = minimumForWidths(widths[0..values.len]); - while (total > limit) { - var changed = false; - var index = values.len; - while (index > 0) { - index -= 1; - if (widths[index] > minimums[index]) { - widths[index] -= 1; - total -= 1; - changed = true; - break; - } - } - if (changed) continue; - - // If the required columns still do not fit, keep the frame inside - // the terminal rather than allowing a hard wrap. - for (widths[0..values.len]) |*width| { - if (total <= limit) break; - if (width.* > 1) { - width.* -= 1; - total -= 1; - } - } - if (total > limit) break; - } - } - try writer.writeStyle(row_style); - for (values, 0..) |value, index| { - if (index != 0) { - if (!header and row_style.len == 0) try writer.writeStyle(style.role.secondary); - try writer.writeAll(" | "); - if (!header and row_style.len == 0) try writer.reset(); - try writer.writeStyle(row_style); - } - if (account_column and index == 1) { - try table_layout.writeAccountTruncatedPadded(writer.out, value, widths[index]); - } else { - try table_layout.writeTruncatedPadded(writer.out, value, widths[index]); - } - } - try writer.reset(); - try writer.writeAll("\n"); -} - -fn minimumForWidths(widths: []const usize) usize { - var total: usize = 0; - for (widths) |width| total += width; - return total + (widths.len - 1) * separator_width; -} - -/// Minimal ISO-8601 parser for the backend shape `YYYY-MM-DDTHH:MM:SS` with an -/// optional trailing zone (`Z` or `±HH:MM`). Returns epoch seconds, or null. -fn parseIsoEpoch(value: []const u8) ?i64 { - if (value.len < 19 or value[4] != '-' or value[7] != '-' or value[10] != 'T' or - value[13] != ':' or value[16] != ':') return null; - const year = std.fmt.parseInt(i64, value[0..4], 10) catch return null; - const month = std.fmt.parseInt(i64, value[5..7], 10) catch return null; - const day = std.fmt.parseInt(i64, value[8..10], 10) catch return null; - const hour = std.fmt.parseInt(i64, value[11..13], 10) catch return null; - const minute = std.fmt.parseInt(i64, value[14..16], 10) catch return null; - const second = std.fmt.parseInt(i64, value[17..19], 10) catch return null; - if (month < 1 or month > 12 or day < 1 or day > 31) return null; - - var epoch = daysFromCivil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second; - if (value.len > 19) { - const zone_marker = value[19]; - if (zone_marker == '+' or zone_marker == '-') { - if (value.len >= 25 and value[22] == ':') { - const zone_hour = std.fmt.parseInt(i64, value[20..22], 10) catch 0; - const zone_minute = std.fmt.parseInt(i64, value[23..25], 10) catch 0; - const offset = zone_hour * 3600 + zone_minute * 60; - epoch += if (zone_marker == '+') -offset else offset; - } - } - } - return epoch; -} - -/// Howard Hinnant's days_from_civil algorithm. -fn daysFromCivil(year: i64, month: i64, day: i64) i64 { - var y = year; - if (month <= 2) y -= 1; - const era = @divFloor(y, 400); - const yoe = y - era * 400; - const mp = @mod(month + 9, 12); - const doy = @divFloor(153 * mp + 2, 5) + day - 1; - const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; - return era * 146097 + doe - 719468; + 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/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..173bf440 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 = 7; 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,98 +126,170 @@ 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 }; + +/// 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, + .credits, +}; +pub const shared_grow_order = [_]ColumnId{ + .rate_5h, + .rate_week, + .plan, + .reset_credits, + .credits, +}; +pub const shared_deferred_order = [_]ColumnId{ .auth_expire, .plan_expire, .last }; + 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 = &shared_first_order, + .grow_order = &shared_grow_order, + .deferred_order = &shared_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 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 -}; +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, .reset_credits, .rate_5h, .rate_week, .auth_expire, .last }; + +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; + + 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; -/// 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; + 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); } - return bounded; + // 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) 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); + } + } } -pub fn switchWidthsToColumns(widths: SwitchWidths) [column_count]usize { - return .{ - widths.email, - widths.plan, - 0, - 0, - widths.rate_5h, - widths.rate_week, - widths.last, - }; +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/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..7ac2a83b 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", "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, @@ -146,6 +147,8 @@ pub fn writeAccountsTableWithUsageOverridesForCols( headers[4].len, 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); @@ -173,26 +176,44 @@ 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); + 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); - content_widths[3] = @max(content_widths[3], reset_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[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], 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); } } - const widths = table_layout.boundColumnWidths(content_widths, prefix_len, term_cols); - var visible: [7]bool = undefined; - var header_cells: [7][]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.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); + 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]; const fallback = compact_headers[index]; header_cells[index] = if (width >= full.len) full - else if (index == 3 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 @@ -237,27 +258,35 @@ 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); + 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); 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]); + 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[4]); + 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 last_cell = try truncateAlloc(last, widths[6]); + const auth_expiry_cell = try truncateAlloc(auth_expiry, widths[7]); + defer std.heap.page_allocator.free(auth_expiry_cell); + const last_cell = try truncateAlloc(last, widths[8]); defer std.heap.page_allocator.free(last_cell); if (use_color) { if (row.is_active) { @@ -270,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 }; + 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/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..d751f0d2 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(); @@ -1748,3 +1775,69 @@ 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, 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 <= 90); + + 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); + var last_index: usize = 0; + 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; + } + 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/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/table_layout_test.zig b/tests/table_layout_test.zig index 76788d3d..eeb6226e 100644 --- a/tests/table_layout_test.zig +++ b/tests/table_layout_test.zig @@ -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" { @@ -246,7 +245,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 +262,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 +277,29 @@ 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 LAST hides before CREDITS" { + 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, "CR") != null); +} 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..53bc9673 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); } @@ -362,3 +363,107 @@ 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; + 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); + var last_index: usize = 0; + 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; + } + + 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); + } +} + +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); + 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); + + 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); +} 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); +}