diff --git a/README.md b/README.md index ee7cb214..956990fc 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,7 @@ Most of these also run as a **direct CLI** with no agent or model involvement, s | Backups | `~/.opencode/backups/` or `~/.opencode/projects//backups/` | | Logs | `~/.opencode/logs/codex-plugin/` | | TUI quota cache | OpenCode state dir plus `oc-codex-multi-auth-tui-quota.json`, else `$OPENCODE_STATE_DIR/oc-codex-multi-auth-tui-quota.json` or `~/.local/state/opencode/oc-codex-multi-auth-tui-quota.json` | +| TUI pool quota cache | `oc-codex-multi-auth-tui-quota-overview.json`, in the same directory, written only when `quotaStatus.mode` is `overview` | Per-project storage is enabled by default. The plugin walks up from the current directory to find a project root, then stores account pools under the project-specific key. If no project root is found, it falls back to global storage. @@ -318,6 +319,132 @@ Primary config files: - `~/.config/opencode/tui.json` - `~/.opencode/openai-codex-auth-config.json` +### Quota percentage display + +Every quota percentage a person reads is worded as the headroom still left, +which is how Codex itself reports a quota: + +```text +5h limit: 88% left # codex-limits, quota details dialog +5h 88% # TUI prompt status line +``` + +Set `quotaDisplay` to `"used"` to report consumption instead: + +```json +{ + "quotaDisplay": "used" +} +``` + +```text +5h limit: 12% used +5h 12% +``` + +Add it to `~/.opencode/openai-codex-auth-config.json`, or set +`CODEX_AUTH_QUOTA_DISPLAY=used`, then quit and restart OpenCode. The setting +covers the TUI prompt status line and quota details dialog, `codex-limits`, +the standalone `limits` CLI, the interactive account check, and the macOS +quota notifications below. + +It changes wording only. Quota exhaustion, rotation blocks, notification +thresholds, and the status line's warning/danger colouring all stay keyed on +the percentage remaining, so a nearly spent account still colours red while +reading `95%`. The `usedPercent` and `leftPercent` fields in `--json` / +`format="json"` output are unaffected. + +### Pool-wide quota status + +The prompt status line describes the account that served the last request. On +a pool of several accounts that account changes as rotation moves, so the line +changes identity under you and no single glance shows where the pool stands. + +Set `quotaStatus.mode` to `"overview"` to describe the whole pool on one +constant line instead, which only changes when a quota does: + +```json +{ + "quotaStatus": { + "mode": "overview", + "layout": "accounts", + "accountNames": "number", + "order": "number", + "multipliers": false, + "allotment": false, + "resetTimes": "low", + "resetCredits": false, + "recovery": false, + "rows": 1, + "showFor": "always" + } +} +``` + +```text +24%: #1 13%, #2 0% 3d, #3 12% # defaults +24%: 3 accounts # "layout": "count" +24%: #1 5x 13%, #2 20x 0% 3d, #3 1x 12% # "multipliers": true +24%: #1 5x 13%, #2 20x 0% 3d 1r, #3 1x 12% # + "resetCredits": true +24%: 3 accounts, +12% in 3d # "layout": "count", "recovery": true +24% of 26x: #1 13%, #2 0% 3d, #3 12% # "allotment": true +24%: #1 13% 2d, #2 0% 3d, #3 12% 5d # "resetTimes": "always" +24%: 13%, 0% 3d, 12% # "accountNames": "none" +24%: damian 13%, work 0% 3d, spare 12% # "accountNames": "label" +24%: #2 0% 3d, #1 13%, #3 12% # "order": "most-used" +24%: 13% 2d, 0% 3d 4d 5d # "layout": "aggregate" +``` + +Each switch is independent, so any combination works. `#N` is the account +number `codex-list` and `codex-switch` use. An account is shown by whichever +of its windows has the least headroom, since that is the one that stops a +request; a reset time (`3d`) is added for an account at or below 25% by +default, for every account under `"resetTimes": "always"`, and for none under +`"never"`. `1r` counts banked rate-limit resets that account can redeem now. + +`order` takes `number`, `most-used`, `least-used`, `renewing-earliest`, or +`renewing-latest`. `layout: "aggregate"` prints a shared percentage once and +keeps only what differs after it, which matters most on a pool where several +accounts are spent. + +The leading figure is the pool total, and it is a **weighted** mean: a Pro seat +spent to 50% has given up twenty times the capacity a Business Standard seat +does at 50%, so an unweighted average would describe a pool nobody has. The +per-plan ratios are listed in [docs/plan-allotments.md](docs/plan-allotments.md), +and `"allotment": true` shows what they add up to. + +`mode` also accepts a list, and the line then alternates between those screens +every `rotateMs` (default 5000). The third screen, `resets`, appears only once +every account is spent and lists the banked reset credits worth redeeming, +latest reset first - redeeming one on an account that renews by itself tomorrow +throws it away: + +```json +{ + "quotaStatus": { "mode": ["overview", "resets"] } +} +``` + +```text +Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com +``` + +`"rows"` (1-4, default 1) is a ceiling rather than a height: a rendering that +fits on one row still takes one, so `"rows": 2` costs nothing on a wide terminal +and buys the whole line back on a narrow one, where the agent/model label beside +it has already wrapped to two rows anyway. `"showFor": "codex-models"` hides the +line unless the session is running a model this plugin routes. + +Percentages follow `quotaDisplay`, so the first line above reads +`76%: #1 87%, #2 100% 3d, #3 88%` under `"used"`. The whole setting is +presentation only: rotation, quota blocks and the line's warning/danger +colouring stay keyed on the headroom remaining. + +Add the object to `~/.opencode/openai-codex-auth-config.json`. It is read from +that file only - a display preference belongs to a person, not to a shell - and +the status line re-reads it while sessions are open, so an edit takes effect +within a couple of seconds without a restart. + ### Desktop quota notifications Quota notifications are an optional macOS-only feature. Separately, the quota @@ -339,6 +466,10 @@ Account identities are omitted for readability and lock-screen privacy: Weekly: 72% | resets 22:30 on Aug 30 ``` +The percentage follows `quotaDisplay`, so the same two lines read `90%` and +`28%` under `"used"`. `thresholds` are always remaining-percent values +regardless. + ```json { "quotaNotifications": { @@ -441,6 +572,8 @@ Selected runtime/environment overrides: | `CODEX_TUI_GLYPHS=ascii\|unicode\|auto` | Force terminal glyph style | | `CODEX_TUI_MASK_EMAIL=0/1` | Mask account emails across account-display surfaces (list/status/limits/health/dashboard/menus + TUI quota status) | | `CODEX_TUI_MASK_EMAIL_DETAILS=0/1` | Also hide account email in quota details when prompt masking is enabled | +| `CODEX_AUTH_QUOTA_DISPLAY=free\|used` | Word quota percentages as headroom left (default, matching Codex) or as consumption | + | `CODEX_AUTH_PER_PROJECT_ACCOUNTS=0/1` | Disable/enable per-project account pools | | `CODEX_AUTH_CREDENTIAL_SNAPSHOTS=0/1` | Disable/enable pre-write snapshots of the credential store (default on) | | `CODEX_AUTH_CREDENTIAL_SNAPSHOTS_MAX_COUNT=` | How many credential snapshots to keep (`0` keeps all of them) | diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index 6a0b11bb..ec082547 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -22,6 +22,7 @@ docs/ ├── getting-started.md # install + first-run guide ├── tools-and-cli.md # 24 codex-* tools + standalone CLI ├── configuration.md # full config reference +├── plan-allotments.md # ChatGPT plan -> allotment multiplier map ├── troubleshooting.md # operational debugging guide ├── faq.md # short common answers ├── privacy.md # data handling notes diff --git a/docs/README.md b/docs/README.md index 740fd6e8..c3e80f00 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ This documentation set is split by purpose so the main README can stay focused o - [Tools and CLI](tools-and-cli.md): complete catalog of 24 `codex-*` tools and standalone bin commands - [Architecture Overview](architecture.md): public map of the installer, OpenCode plugin entry, TUI plugin, tool registry, request pipeline, rotation, and storage model - [Configuration Reference](configuration.md): config keys, environment variables, fallback behavior, and file locations +- [Plan Allotments](plan-allotments.md): what each ChatGPT plan is worth relative to a 1x seat, and how the pool-wide quota total is weighted - [Troubleshooting](troubleshooting.md): common failure modes and recovery steps - [FAQ](faq.md): short answers for common questions - [Privacy & Data Handling](privacy.md): what is stored locally, what is sent upstream, and how to delete it diff --git a/docs/configuration.md b/docs/configuration.md index e8c7dc55..a00bb43e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -207,6 +207,21 @@ a restart to change their configuration. "codexTuiGlyphMode": "ascii", "maskEmail": false, "maskEmailInQuotaDetails": false, + "quotaDisplay": "free", + "quotaStatus": { + "mode": "active", + "rotateMs": 5000, + "layout": "accounts", + "accountNames": "number", + "order": "number", + "multipliers": false, + "allotment": false, + "resetTimes": "low", + "resetCredits": false, + "recovery": false, + "rows": 1, + "showFor": "always" + }, "beginnerSafeMode": false, "fastSession": false, "fastSessionStrategy": "hybrid", @@ -273,6 +288,8 @@ The sample above intentionally sets `"retryAllAccountsMaxRetries": 3` as a bound | `codexTuiGlyphMode` | `ascii` | glyph set for codex ui (`ascii`, `unicode`, `auto`) | | `maskEmail` | `false` | masks account emails across account-display surfaces: the TUI prompt quota status, command output (`codex-list`, `codex-status`, `codex-limits`, `codex-health`, `codex-dashboard`, `codex-refresh`, `codex-switch`, `codex-label`, `codex-tag`, `codex-note`, `codex-remove`), the interactive account menu, and the standalone login menu. Account labels (set via `codex-label`) are preferred and always shown; emails are reduced to a masked form such as `us***@example.com`. Raw emails are still emitted in `--includeSensitive` JSON output, which is opt-in. | | `maskEmailInQuotaDetails` | `false` | also masks the active account email in the quota details dialog when `maskEmail` is enabled | +| `quotaDisplay` | `free` | wording of every quota percentage a person reads: `free` reports the headroom left (`5h limit: 88% left`), matching how Codex itself reports a quota; `used` reports consumption instead (`5h limit: 12% used`). Covers the TUI prompt status line and quota details dialog, `codex-limits`, the standalone `limits` CLI, the interactive account check, and macOS quota notifications. Presentation only: exhaustion, rotation blocks, notification thresholds, and the status line's warning/danger colouring stay keyed on the remaining percentage, and the `usedPercent` / `leftPercent` fields in JSON output are unchanged. | +| `quotaStatus` | `mode: active` | shape of the TUI prompt status line. `active` describes the account serving requests, `overview` describes the whole pool on one constant line, `resets` lists redeemable reset credits once nothing has headroom left. A list of screens alternates between them. File-only; no environment override. See [Pool-wide quota status](#pool-wide-quota-status). | | `beginnerSafeMode` | `false` | enables conservative beginner-safe runtime behavior for retries and recovery | | `fastSession` | `false` | forces low-latency settings per request (`reasoningEffort=none/low`, `reasoningSummary=auto`, `textVerbosity=low`) | | `fastSessionStrategy` | `hybrid` | `hybrid` speeds simple turns and keeps full-depth for complex prompts; `always` forces fast mode every turn | @@ -380,6 +397,179 @@ existing pool with `codex-pool action="set-mode" model="gpt-5.6-sol" poolMode="strict"`. Routing diagnostics expose `general`, `preferred`, `general-fallback`, `strict`, or `strict-unavailable`. +### Pool-wide quota status + +By default the prompt status line describes the account that served the last +request. On a pool of several accounts that account changes as rotation moves, +so the line changes identity while you work and no single glance shows where +the pool stands. + +Set `quotaStatus.mode` to `overview` to describe the whole pool on one line +instead: + +```json +{ + "quotaStatus": { + "mode": "overview" + } +} +``` + +```text +24%: #1 87%, #2 0% 3d, #3 88% +``` + +The leading figure is the pool total. It is a **weighted** mean: a Pro seat +spent to 50% has given up twenty times the capacity a Business Standard seat +does at 50%, so each account is weighted by its plan's allotment. See +[plan allotments](plan-allotments.md) for the map and its sources. + +Each account is shown by its 1-based `codex-list` number and the window with +the least headroom left, which is the one that would stop a request. A reset +time is printed only for an account at or below 25% headroom; every account has +a reset, and printing all of them triples the length of the line. + +Percentages follow [`quotaDisplay`](#options), so the same pool reads `24%` as +headroom or `76%` as consumption. + +The whole `quotaStatus` object is read from the config file only. It is a +display preference that belongs to a person rather than to whichever shell +started OpenCode, so there is no environment override for any field in it. + +#### What the line says + +| Field | Default | Effect | +| --- | --- | --- | +| `layout` | `accounts` | `accounts` gives one segment per account; `aggregate` collapses accounts that share a percentage; `count` gives `24%: 3 accounts` | +| `accountNames` | `number` | `number` gives `#1`; `label` gives the account's `codex-label` label, or its email's local part; `none` drops the name | +| `order` | `number` | `number`, `most-used`, `least-used`, `renewing-earliest`, `renewing-latest` | +| `multipliers` | `false` | `5x` / `20x` plan allotment badges | +| `allotment` | `false` | `24% of 26x`, what the pool the percentage is averaged over adds up to | +| `resetTimes` | `low` | `never`, `low` (only accounts at or below 25% headroom), or `always` | +| `resetCredits` | `false` | `1r` for banked rate-limit resets redeemable now | +| `recovery` | `false` | `+12% in 3d`, how far the pool total moves at the next reset | + +With everything on: + +```text +24% of 26x: #1 5x 87%, #2 20x 0% 3d 1r, #3 1x 88%, +3% in 2d +``` + +`resetTimes: "always"` answers a question `low` cannot: 90% spent with an hour +to go and 90% spent with six days to go are not the same situation. + +```text +24%: #1 87% 2d, #2 0% 3d, #3 88% 5d +``` + +`layout: "aggregate"` is for a pool where several accounts read the same +number. The percentage is printed once and only what differs follows it, so +three spent accounts cost one segment rather than three: + +```text +72%: 12% 3d, 50% 4d, 100% 3d 1r 4d 5d +``` + +A group states its own size - `100% x3 4d 5d` - when its annotations would not +already reveal it. Grouping discards identity by construction, so +`accountNames` has no effect under this layout. + +`accountNames: "none"` leaves position to identify the accounts, which only +works while they are in `number` order and every one of them is readable: + +```text +24%: 87%, 0% 3d, 88% +``` + +The recovery clause is signed to match the direction the figure beside it +moves, so it reads `+12% in 3d` under `free` and `-12% in 3d` under `used`. + +#### Rotating between screens + +`mode` accepts a list, and the line then alternates between its entries every +`rotateMs` (default 5000, minimum 1000): + +```json +{ + "quotaStatus": { + "mode": ["overview", "resets"], + "rotateMs": 5000 + } +} +``` + +A screen with nothing to say is skipped rather than shown blank, which is what +makes `resets` worth leaving in the list permanently. It renders only once +**every** account is spent, and lists the banked reset credits worth redeeming, +latest reset first - because redeeming a credit on an account that renews by +itself tomorrow throws the credit away, while the account six days out is the +one worth spending it on: + +```text +Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com +``` + +That line honours [`maskEmail`](#options). It shortens by giving up the word +`Free`, then the address (to a label, then to `#1`), then the countdown, then +the credit counts, and finally becomes `Resets: 2`. + +#### How much room the line takes + +```json +{ + "quotaStatus": { + "rows": 2 + } +} +``` + +`rows` (1 to 4, default 1) is a **ceiling, not a height**. A rendering that +fits on one row still takes one, so raising it costs nothing on a wide terminal +and buys the whole line back on a narrow one, where the agent/model label +beside it has already wrapped to two rows anyway. Rows break only at the `, ` +between accounts, so a row never ends mid-account. + +The space available is measured from the laid-out prompt row rather than +computed from the terminal width, since an open sidebar takes a share nothing +in the plugin can derive. The model label's own width is deliberately *not* +measured: the row sizes both boxes by their content, so a label with no room +left is shrunk to whatever this line did not take, and reading its width would +make the budget a function of the line's own length. + +Within that space the line degrades in the order that costs a reader the least: +the recovery clause and the pool allotment, then the badges and banked resets, +then reset countdowns, then the account names, then the per-account breakdown +(`3 accounts` -> `3 acct.` -> `3`), and finally the pool total alone. A switch +left off never reappears because the terminal happens to be wide. + +#### When the line appears + +```json +{ + "quotaStatus": { + "showFor": "always" + } +} +``` + +`always` (the default) shows the line whenever accounts are configured, +whichever model the session is running. `codex-models` shows it only while the +session is running a model this plugin routes. A session that has not run +anything yet still shows the line. + +#### Where the numbers come from + +Quota for the whole pool is read from `/wham/usage` on a five-minute interval +and cached at `oc-codex-multi-auth-tui-quota-overview.json` in the OpenCode +state directory, so several OpenCode windows on one machine share a single +round of requests. The account currently serving requests is refreshed from +response headers after every response and folded into the cached pool, so its +figure stays live between polls. + +Add the configuration to `~/.opencode/openai-codex-auth-config.json`. The +status line re-reads that file while sessions are open, so an edit takes effect +within a couple of seconds without a restart. + ### Beginner Safe Mode Behavior when `beginnerSafeMode` is enabled (`true` or `CODEX_AUTH_BEGINNER_SAFE_MODE=1`), the plugin applies a safer retry profile automatically: @@ -458,6 +648,8 @@ override any config with env vars (boolean values are truthy only for `"1"`): | `CODEX_TUI_GLYPHS=unicode` | override glyph mode (`ascii`, `unicode`, `auto`) | | `CODEX_TUI_MASK_EMAIL=1` | mask account emails across account-display surfaces (TUI prompt quota status, command output, interactive account menu, and standalone login menu) | | `CODEX_TUI_MASK_EMAIL_DETAILS=1` | also mask the active account email in quota details when prompt masking is enabled | +| `CODEX_AUTH_QUOTA_DISPLAY=free\|used` | word quota percentages as headroom left (default) or as consumption | + | `CODEX_AUTH_PREWARM=0` | disable startup prewarm when legacy transform is enabled (native mode does not prewarm) | | `CODEX_AUTH_TOKEN_REFRESH_SKEW_MS=60000` | refresh OAuth tokens this many ms before expiry | | `CODEX_AUTH_RATE_LIMIT_TOAST_DEBOUNCE_MS=60000` | debounce rate-limit toast notifications | @@ -626,6 +818,7 @@ opencode run "task" --model=openai/gpt-5.6-sol-high | `~/.opencode/logs/codex-plugin/` | request/debug logs when enabled | | `~/.opencode/cache/` | instruction/catalog and auto-update caches | | `~/.local/state/opencode/oc-codex-multi-auth-tui-quota.json` | TUI quota snapshot cache shared by the provider and TUI plugins; `$OPENCODE_STATE_DIR` overrides the directory when set | +| `~/.local/state/opencode/oc-codex-multi-auth-tui-quota-overview.json` | pool-wide quota snapshot cache, written only when `quotaStatus.mode` includes `overview` or `resets`; same directory resolution as above | | `$XDG_DATA_HOME/opencode/storage/…` (Windows: `%APPDATA%/opencode/storage`) | OpenCode session message/part store (session recovery) | | `openai-codex-accounts.json` / `openai-codex-flagged-accounts.json` / `openai-codex-blocked-accounts.json` | legacy migration sources only | diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 579331bf..6d13c72b 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -96,6 +96,10 @@ tui.ts | Installer CLI | `scripts/install-oc-codex-multi-auth.js`, `scripts/install-oc-codex-multi-auth-core.js` | npm bin; config merge; cache cleanup; modern/full/legacy catalog selection; standalone doctor/status/list/limits/dashboard/health/diag/warm; TUI plugin enablement | | OpenCode plugin entry | `index.ts` | auth loader, runtime wiring, custom fetch pipeline, account manager lifecycle, `ToolContext`, OpenCode plugin export | | TUI plugin entry | `tui.ts`, `lib/tui-status.ts`, `lib/tui-quota-cache.ts`, `lib/codex-usage.ts` | prompt quota status, account-aware quota snapshots, usage refresh, details rendering | +| Quota percentage wording | `lib/quota-display.ts` | `quotaDisplay` free/used rendering shared by the TUI, `codex-limits`, the standalone CLI, and notifications; a leaf module so the status line and the usage surfaces can both depend on it | +| Pool-wide status line | `lib/quota-overview.ts`, `lib/tui-quota-overview.ts` | `quotaStatus.mode` `overview` / `resets`; the first is a pure formatter (weighted total, ordering, layouts, degradation ladder, reset-credit line), the second gathers and caches every account's usage and merges the request path's live reading of the serving account | +| Status slot layout | `tui.ts` (`measureStatusSlot`, `resolveStatusRows`), `lib/tui-status.ts` (`wrapStatusCandidate`, `fitStatusLines`) | measures the columns and rows the prompt actually left this slot, and lays a candidate ladder out across them | +| Plan allotments | `lib/plan-allotment.ts` | `plan_type` to weight/multiplier/price; another leaf, so the render path weights the pool total without pulling in JWT decoding | | Auth flow | `lib/auth/auth.ts`, `lib/auth/loopback-flow.ts`, `lib/auth/server.ts`, `lib/auth/browser.ts`, `lib/auth/device-code.ts`, `lib/auth/login-runner.ts`, `lib/auth/scopes.ts` | PKCE OAuth, callback server, default-browser and open-URL-manually listener flows, device code, manual URL paste, workspace/account selection, scope validation | | Account manager | `lib/accounts.ts`, `lib/accounts/` | account state facade, persistence, rotation, recovery, rate-limit tracking, workspace identity preservation, warm | | Storage | `lib/storage.ts`, `lib/storage/` | V3 JSON storage, atomic writes, migrations, per-project paths, backups, import/export, keychain opt-in, flagged accounts | @@ -273,6 +277,28 @@ The request path also writes quota snapshots from response headers, so the TUI c The shared cache file resolves in this order. `tui.ts` passes the OpenCode state path (`api.state.path.state`) to `getTuiQuotaCachePath`. That function falls back to `$OPENCODE_STATE_DIR`, then to `~/.local/state/opencode/oc-codex-multi-auth-tui-quota.json`. There is no `~/.opencode/` fallback. +`quotaStatus.mode` names the screen, or the list of screens to alternate between every `rotateMs`. One node stays mounted for the whole session and reads from whichever pipelines the current screens need, so a config edit never asks the renderer to replace a live node: + +- `active` is the pipeline above: one serving account, a fingerprint, and a one-second identity poll. +- `overview` and `resets` share the pool pipeline, since `resets` needs exactly the windows and banked credits that pipeline already gathers. + +The pool pipeline: + +1. Read the pool snapshot from `oc-codex-multi-auth-tui-quota-overview.json` in that same directory. +2. Re-query `/wham/usage` for every deduplicated enabled account when that snapshot has aged past the refresh interval, and write it back. +3. Merge the single-account snapshot above when it is newer, so the account currently serving requests shows header-fresh numbers rather than poll-aged ones. +4. Render through `formatQuotaOverviewCandidates` (or `formatQuotaResetsCandidates`), taking the first rung that fits. + +The two caches stay separate files on purpose: the request path rewrites the single-account one after every response, and folding them together would make each request rewrite a document describing accounts that request never touched. + +The space the line has is **measured**, not computed. `measureStatusSlot` walks up from the mounted node to the prompt's bottom row and takes that row's width, less a constant for the model label, because an open sidebar takes a share nothing in the plugin can derive from the terminal width. Every hop is duck-typed and guarded, and an unfamiliar tree degrades to the old width heuristic rather than budgeting from numbers that no longer mean what they did. + +The label's own width and height are deliberately **not** measured, and both were tried and reverted during QA. The row sizes both boxes by their content with `alignItems: stretch`, so the label box reports this line's own height once this line grows, and is shrunk to whatever this line did not take once the row is full. Either reading makes the budget a function of its own output: the height version latched `rows: "auto"` at two rows permanently, and the width version ratchets the column budget down on every render. The row's width is the only number on that row this line cannot influence. + +`rows` is therefore a plain ceiling (1-4, default 1), not a measurement. It costs nothing until the content needs the room, since a candidate that fits on one row still returns one row. A second row is one `text` node with a newline in it, so the renderer measures it and the node sizes itself; `alignSelf: "flex-start"` keeps it on the top row, since the host centres this slot against a label that wraps. + +A screen that renders nothing is skipped in the rotation rather than shown blank, which is what lets `resets` sit in the list permanently and surface only on the day every account is spent. + --- ## Model Catalog and Fallback Notes diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 1e6a221f..7974ada0 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -227,6 +227,26 @@ Defaults come from `lib/config.ts` / `lib/schemas.ts`. Environment overrides win | `codexTuiGlyphMode` | `ascii` | `CODEX_TUI_GLYPHS` | `ascii` / `unicode` / `auto` | | `maskEmail` | `false` | `CODEX_TUI_MASK_EMAIL` | Mask account emails on display surfaces | | `maskEmailInQuotaDetails` | `false` | `CODEX_TUI_MASK_EMAIL_DETAILS` | Also mask email in quota details | +| `quotaDisplay` | `free` | `CODEX_AUTH_QUOTA_DISPLAY` | Word quota percentages as `free` headroom or `used` consumption; presentation only | +| `quotaStatus.mode` | `active` | (file only) | Screen, or list of screens to alternate between: `active`, `overview`, `resets` | +| `quotaStatus.rotateMs` | `5000` | (file only) | How long each screen stays up when `mode` is a list; minimum 1000 | +| `quotaStatus.layout` | `accounts` | (file only) | `accounts`, `aggregate` (collapse a shared percentage), or `count` (`3 accounts`) | +| `quotaStatus.accountNames` | `number` | (file only) | `number` (`#1`), `label` (`codex-label` label or email local part), or `none` | +| `quotaStatus.order` | `number` | (file only) | `number`, `most-used`, `least-used`, `renewing-earliest`, `renewing-latest` | +| `quotaStatus.multipliers` | `false` | (file only) | `5x` / `20x` plan allotment badges | +| `quotaStatus.allotment` | `false` | (file only) | `24% of 26x`: what the weighted pool adds up to in 1x seats | +| `quotaStatus.resetTimes` | `low` | (file only) | `never`, `low` (at or below 25% headroom), or `always` | +| `quotaStatus.resetCredits` | `false` | (file only) | `1r` for banked rate-limit resets redeemable now | +| `quotaStatus.recovery` | `false` | (file only) | `+12% in 3d`: how far the pool total moves at the next reset | +| `quotaStatus.rows` | `1` | (file only) | Ceiling on the rows the line may take (1-4). A rendering that fits on one row still takes one | +| `quotaStatus.showFor` | `always` | (file only) | `always`, or `codex-models` to hide the line unless the session runs a model this plugin routes | + +`quotaStatus` is deliberately file-only: it is a display preference belonging to +a person, not to whichever shell started OpenCode. `resetTimes` also accepts the +boolean spelling an earlier build took (`true` -> `low`, `false` -> `never`), +and `accounts: false` is honoured as `layout: "count"`, because +`PluginConfigSchema` validates the file as one unit and one stale value would +otherwise reset every other setting in it. | `beginnerSafeMode` | `false` | `CODEX_AUTH_BEGINNER_SAFE_MODE` | Conservative retries and recovery | | `fastSession` | `false` | `CODEX_AUTH_FAST_SESSION` | Force low-latency reasoning/verbosity | | `fastSessionStrategy` | `hybrid` | `CODEX_AUTH_FAST_SESSION_STRATEGY` | `hybrid` or `always` | diff --git a/docs/plan-allotments.md b/docs/plan-allotments.md new file mode 100644 index 00000000..f5aba764 --- /dev/null +++ b/docs/plan-allotments.md @@ -0,0 +1,86 @@ +# ChatGPT plan allotments + +Every account the plugin holds reports a `plan_type`, and those plans do not +carry the same amount of Codex capacity. This page is the reference map from +that slug to the plan's name, its allotment relative to a 1x seat, and the +per-seat monthly price OpenAI lists it at. + +The map lives in code at [`lib/plan-allotment.ts`](../lib/plan-allotment.ts) +and is covered by `test/plan-allotment.test.ts`. Update both together. + +## Where `plan_type` comes from + +Two places, which agree: + +- the `chatgpt_plan_type` claim inside the OAuth access token, read by + `lib/auth/plan-tier.ts` and stored with the account, and +- the `plan_type` field on the `/wham/usage` response, read live by + `codex-limits` and the TUI. + +`lib/auth/plan-tier.ts` turns the slug into the subscription name OpenAI shows. +`lib/plan-allotment.ts` turns the same slug into the allotment. The two are +deliberately separate: naming a plan needs the token decoder, and weighting one +needs nothing at all, so the status line can weight a pool without pulling JWT +handling into its render path. + +## The map + +| `plan_type` | Plan | Allotment | Monthly (USD) | +| --- | --- | --- | --- | +| `free` | ChatGPT Free | — | 0 | +| `go` | ChatGPT Go | — | — | +| `plus` | ChatGPT Plus | 1x | 20 | +| `team` | ChatGPT Business (still emitted under the old name) | 1x | 25 | +| `business` | ChatGPT Business, seat unstated | — | — | +| `business_standard` | ChatGPT Business Standard | 1x | 25 | +| `self_serve_business_prolite` | ChatGPT Business Premium | 5x | 125 | +| `prolite` | ChatGPT Pro Lite | 5x | 100 | +| `pro` | ChatGPT Pro | 20x | 200 | +| `pro 5x`, `pro 100`, `pro legacy` | ChatGPT Pro (legacy $100) | 5x | 100 | +| `enterprise` | ChatGPT Enterprise | — | negotiated | + +Three entries are not derivable from their text and are matched explicitly: + +- **`team` is Business.** OpenAI renamed the product and kept the slug. +- **`self_serve_business_prolite` is the premium Business *seat*,** not the + personal Pro Lite tier that shares the `prolite` token. They are priced + differently ($125 against $100), so the `business` qualifier decides. +- **A bare `business` names the workspace, not the seat.** The two seats inside + it are 5x apart, so no ratio can be stated for it. + +An unrecognized slug states no ratio rather than guessing one. + +## What the allotment is + +The published per-seat ratio against the 1x Plus / Business Standard seat, +taken from the monthly price: Pro is $200 against $20 and is marketed as 20x. +It describes the *subscription*, which is the only ratio OpenAI states — not a +measured token allowance. + +## What uses it + +The pool-wide prompt status line (`quotaStatus.mode: "overview"`, see +[configuration](configuration.md#pool-wide-quota-status)). A pool of mixed +plans has no single "percent used": a Pro seat spent to 50% has given up twenty +times the capacity a Business Standard seat does at 50%, so the pool total is a +mean weighted by these allotments rather than a plain average. + +A plan that states no ratio is weighted as one baseline seat. That +under-weights it, which understates one account; weighting it higher would let +a plan the code failed to recognize dominate the figure the whole pool is +judged by. + +Turn `quotaStatus.multipliers` on to print the badge beside each account: + +```text +24%: #1 5x 13%, #2 20x 100% 3d, #3 1x 12% +``` + +## Keeping it current + +OpenAI changes plans and prices. When that happens, update +`lib/plan-allotment.ts`, this table, and `test/plan-allotment.test.ts` in one +commit. The same map is mirrored outside this repository in DreamHost's +`ai-api-usage-tracker` (`src/plan-tier.ts`, `web/src/planTier.ts`, and +`extension/src/plan/codex/labels.ts`), which is where these figures were taken +from; that project keeps its three copies in sync with parity tests. diff --git a/index.ts b/index.ts index 64042f54..3a3d5de3 100644 --- a/index.ts +++ b/index.ts @@ -101,6 +101,7 @@ import { getCodexTuiGlyphMode, getBeginnerSafeMode, getCodexTuiMaskEmail, + getQuotaDisplay, loadPluginConfig, } from "./lib/config.js"; import { @@ -4189,20 +4190,25 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ); }; + const authQuotaDisplay = getQuotaDisplay(authPluginConfig); const formatCodexQuotaLine = (usage: CodexUsageSummary): string => { const parts: string[] = []; for (const window of [usage.primary, usage.secondary]) { if (!hasUsageWindow(window)) continue; parts.push( - `${formatUsageLimitTitle(window.windowMinutes)} ${formatUsageLimitSummary(window)}`, + `${formatUsageLimitTitle(window.windowMinutes)} ${formatUsageLimitSummary(window, authQuotaDisplay)}`, ); } if (hasUsageWindow(usage.codeReview)) { - parts.push(`Code review ${formatUsageLimitSummary(usage.codeReview)}`); + parts.push( + `Code review ${formatUsageLimitSummary(usage.codeReview, authQuotaDisplay)}`, + ); } for (const limit of usage.additionalLimits) { if (hasUsageWindow(limit.window)) { - parts.push(`${limit.name} ${formatUsageLimitSummary(limit.window)}`); + parts.push( + `${limit.name} ${formatUsageLimitSummary(limit.window, authQuotaDisplay)}`, + ); } } const planLabel = formatPlanType(usage.planType); @@ -4454,7 +4460,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { organizationId: account.organizationId, normalizeAccountErrors: true, }); - const usage = parseCodexUsagePayload(payload); + const usage = parseCodexUsagePayload(payload, authQuotaDisplay); ok += 1; console.log( `[${i + 1}/${total}] ${label}: ${formatCodexQuotaLine(usage)}`, diff --git a/lib/AGENTS.md b/lib/AGENTS.md index 8e9c0aa5..ace5fbbd 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -29,10 +29,13 @@ lib/ ├── oauth-constants.ts # OAuth port/path constants ├── oauth-success.ts # OAuth success HTML source copied during build ├── parallel-probe.ts # parallel account probes, first success wins +├── plan-allotment.ts # ChatGPT plan_type -> allotment weight/multiplier/price ├── proactive-refresh.ts # token refresh before expiry ├── prompts/ # Codex/OpenCode prompts and ETag caches +├── quota-display.ts # free/used wording for every human-readable quota percentage ├── quota-notification-state.ts # cross-process threshold/delivery state file ├── quota-notifications.ts # aggregate quota poller and threshold transitions +├── quota-overview.ts # pure pool-wide status line formatting + weighted total ├── recovery.ts # recovery barrel / compatibility entry ├── recovery/ # session recovery hook, storage, constants, types ├── refresh-queue.ts # queued token refresh (race prevention) @@ -45,7 +48,8 @@ lib/ ├── storage/ # atomic writes, paths, migrations, keychain, backup/import/export ├── table-formatter.ts # CLI table formatting ├── tools/ # 24 codex-* tool factories + registry -├── tui-quota-cache.ts # shared quota snapshot cache +├── tui-quota-cache.ts # shared quota snapshot cache (active account + pool overview) +├── tui-quota-overview.ts # pool-wide quota gathering, caching, and live-account merge ├── tui-status.ts # prompt quota status formatting ├── types.ts # TypeScript interfaces ├── types/ # dependency type shims @@ -82,6 +86,9 @@ lib/ | Test-home write guard | `storage/test-home-guard.ts` | refuses storage writes inside the real home during a vitest run | | Tool registry | `tools/index.ts` | `ToolContext`, `createToolRegistry` | | TUI quota status | `tui-status.ts`, `tui-quota-cache.ts`, `codex-usage.ts` | prompt quota display and usage cache | +| Quota percentage wording | `quota-display.ts` | `quotaDisplay` free/used rendering shared by the TUI, `codex-limits`, the standalone CLI, and notifications; presentation only, so exhaustion and tone stay on the remaining percentage | +| Pool-wide status line | `quota-overview.ts`, `tui-quota-overview.ts` | `quotaStatus.mode` `overview` / `resets` renders every account on one constant line; `quota-overview.ts` is a pure formatter (ordering, `accounts`/`aggregate`/`count` layouts, degradation ladder, reset-credit line), `tui-quota-overview.ts` gathers/caches the pool and merges the request path's live reading of the serving account | +| Plan allotments | `plan-allotment.ts` | `plan_type` -> weight/multiplier/price, used to weight the pool total and to render `5x` badges; see `docs/plan-allotments.md` | | Error types | `errors.ts`, `error-sentinels.ts` | StorageError and structured sentinel errors | | Health monitoring | `health.ts` | account health status | | Account display / masking | `account-display.ts` | label-preferred rendering, `maskEmail` behavior | diff --git a/lib/codex-usage.ts b/lib/codex-usage.ts index 37f0bec8..7b7e8bd1 100644 --- a/lib/codex-usage.ts +++ b/lib/codex-usage.ts @@ -10,6 +10,11 @@ import { createUsageRequestTimeoutError, } from "./error-sentinels.js"; import { logWarn } from "./logger.js"; +import { + DEFAULT_QUOTA_DISPLAY_MODE, + formatNamedQuotaPercent, + type QuotaDisplayMode, +} from "./quota-display.js"; import { isQuotaWindowExhausted, MAX_QUOTA_RESET_HORIZON_MS, @@ -213,11 +218,16 @@ export function formatUsageLimitTitle( return `${formatUsageWindowLabel(windowMinutes)} limit`; } -export function formatUsageLimitSummary(window: LimitWindow): string { +export function formatUsageLimitSummary( + window: LimitWindow, + mode: QuotaDisplayMode = DEFAULT_QUOTA_DISPLAY_MODE, +): string { const left = getUsageLeftPercent(window.usedPercent); const reset = formatUsageReset(window.resetAtMs); - if (left !== undefined && reset) return `${left}% left (resets ${reset})`; - if (left !== undefined) return `${left}% left`; + const percent = + left !== undefined ? formatNamedQuotaPercent(left, mode) : undefined; + if (percent && reset) return `${percent} (resets ${reset})`; + if (percent) return percent; if (reset) return `resets ${reset}`; return "unavailable"; } @@ -225,6 +235,7 @@ export function formatUsageLimitSummary(window: LimitWindow): string { export function toUsageLimitPayload( name: string, window: LimitWindow, + mode: QuotaDisplayMode = DEFAULT_QUOTA_DISPLAY_MODE, ): UsageLimitPayload { return { name, @@ -233,7 +244,7 @@ export function toUsageLimitPayload( typeof window.usedPercent === "number" ? window.usedPercent : null, leftPercent: getUsageLeftPercent(window.usedPercent) ?? null, resetAtMs: window.resetAtMs ?? null, - summary: formatUsageLimitSummary(window), + summary: formatUsageLimitSummary(window, mode), }; } @@ -463,6 +474,7 @@ export async function persistUsageQuotaRecovery(account: AccountMetadataV3): Pro */ export function parseCodexUsagePayload( payload: UsagePayload | null | undefined, + mode: QuotaDisplayMode = DEFAULT_QUOTA_DISPLAY_MODE, ): CodexUsageSummary { const source: UsagePayload = typeof payload === "object" && payload !== null ? payload : {}; @@ -496,14 +508,18 @@ export function parseCodexUsagePayload( for (const window of [primary, secondary]) { if (!hasUsageWindow(window)) continue; limits.push( - toUsageLimitPayload(formatUsageLimitTitle(window.windowMinutes), window), + toUsageLimitPayload( + formatUsageLimitTitle(window.windowMinutes), + window, + mode, + ), ); } if (hasUsageWindow(codeReview)) { - limits.push(toUsageLimitPayload("Code review", codeReview)); + limits.push(toUsageLimitPayload("Code review", codeReview, mode)); } for (const limit of additionalLimits) { - limits.push(toUsageLimitPayload(limit.name, limit.window)); + limits.push(toUsageLimitPayload(limit.name, limit.window, mode)); } return { diff --git a/lib/config.ts b/lib/config.ts index c30d9bb4..977b34a0 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -10,6 +10,17 @@ import { type RetryProfile, } from "./request/retry-budget.js"; import { logWarn } from "./logger.js"; +import { + DEFAULT_QUOTA_DISPLAY_MODE, + QUOTA_DISPLAY_MODES, + type QuotaDisplayMode, +} from "./quota-display.js"; +import type { + QuotaOverviewLayout, + QuotaOverviewNames, + QuotaOverviewOrder, + QuotaOverviewResetTimes, +} from "./quota-overview.js"; import { stripEffortSuffix } from "./request/helpers/effort-suffix.js"; import { isWindowsLockError, @@ -31,6 +42,7 @@ const TUI_GLYPH_MODES = new Set(["ascii", "unicode", "auto"]); const REQUEST_TRANSFORM_MODES = new Set(["native", "legacy"]); const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]); const RETRY_PROFILES = new Set(["conservative", "balanced", "aggressive"]); +const QUOTA_DISPLAY_MODE_SET: ReadonlySet = new Set(QUOTA_DISPLAY_MODES); export type UnsupportedCodexPolicy = "strict" | "fallback"; @@ -73,6 +85,7 @@ const DEFAULT_CONFIG: PluginConfig = { codexTuiGlyphMode: "ascii", maskEmail: false, maskEmailInQuotaDetails: false, + quotaDisplay: DEFAULT_QUOTA_DISPLAY_MODE, beginnerSafeMode: false, fastSession: false, fastSessionStrategy: "hybrid", @@ -698,6 +711,23 @@ export function getCodexTuiMaskEmailInQuotaDetails( ); } +/** + * Whether quota percentages are worded as headroom or as consumption. + * + * Defaults to `free`, which is how Codex itself reports a quota. Only the + * wording changes: exhaustion, rotation blocks, notification thresholds and + * the status line's warning/danger colouring all stay keyed on the remaining + * percentage. + */ +export function getQuotaDisplay(pluginConfig: PluginConfig): QuotaDisplayMode { + return resolveStringSetting( + "CODEX_AUTH_QUOTA_DISPLAY", + pluginConfig.quotaDisplay, + DEFAULT_QUOTA_DISPLAY_MODE, + QUOTA_DISPLAY_MODE_SET, + ); +} + export function getFastSession(pluginConfig: PluginConfig): boolean { return resolveBooleanSetting( "CODEX_AUTH_FAST_SESSION", @@ -1179,3 +1209,160 @@ export function getQuotaNotifications( thresholds, }; } + +/** One thing the prompt status line can be showing at a given moment. */ +export type QuotaStatusScreen = "active" | "overview" | "resets"; + +/** Whether the line appears for every model or only for the ones it describes. */ +export type QuotaStatusAudience = "always" | "codex-models"; + +const QUOTA_STATUS_SCREENS: readonly QuotaStatusScreen[] = [ + "active", + "overview", + "resets", +]; +const QUOTA_STATUS_AUDIENCES: readonly QuotaStatusAudience[] = [ + "always", + "codex-models", +]; +const QUOTA_OVERVIEW_LAYOUTS: readonly QuotaOverviewLayout[] = [ + "accounts", + "aggregate", + "count", +]; +const QUOTA_OVERVIEW_NAMES: readonly QuotaOverviewNames[] = [ + "number", + "label", + "none", +]; +const QUOTA_OVERVIEW_ORDERS: readonly QuotaOverviewOrder[] = [ + "number", + "most-used", + "least-used", + "renewing-earliest", + "renewing-latest", +]; +const QUOTA_OVERVIEW_RESET_TIMES: readonly QuotaOverviewResetTimes[] = [ + "never", + "low", + "always", +]; +const DEFAULT_QUOTA_STATUS_ROTATE_MS = 5_000; +const MIN_QUOTA_STATUS_ROTATE_MS = 1_000; +const MAX_QUOTA_STATUS_ROWS = 4; + +export interface QuotaStatusConfig { + /** + * The screens to show, in the order they take turns. `active` names the + * account serving requests, which is how the status line has always + * worked; `overview` describes the whole pool; `resets` lists the banked + * reset credits worth redeeming once nothing has headroom left. More than + * one screen alternates every {@link rotateMs}. + */ + screens: QuotaStatusScreen[]; + rotateMs: number; + /** One segment per account, one per distinct percentage, or just a count. */ + layout: QuotaOverviewLayout; + /** `#1`, the account's own name, or nothing at all. */ + accountNames: QuotaOverviewNames; + order: QuotaOverviewOrder; + /** `5x` / `20x` plan allotment badges. */ + multipliers: boolean; + /** `66% of 65x`: what the pool the percentage is taken over adds up to. */ + allotment: boolean; + /** Which accounts get a `3d` countdown: none, the low ones, or all. */ + resetTimes: QuotaOverviewResetTimes; + /** `1r` for banked rate-limit resets redeemable now. */ + resetCredits: boolean; + /** `+12% in 3d`: how far the pool total moves at the next reset. */ + recovery: boolean; + /** + * Rows the line may occupy. A ceiling rather than a height: a rendering + * that fits on one row still takes one, so raising this costs nothing until + * the terminal is narrow enough for the line to need the room. + */ + rows: number; + showFor: QuotaStatusAudience; +} + +function pickEnum( + value: unknown, + allowed: readonly T[], + fallback: T, +): T { + return allowed.find((entry) => entry === value) ?? fallback; +} + +/** + * The screens to rotate through, from either a single value or a list. + * + * Unknown names are dropped rather than failing: this is presentation, and a + * typo should cost the line its extra screen, not the session its status. + * Duplicates are collapsed so a list cannot make one screen come up twice as + * often as the others. + */ +function resolveQuotaStatusScreens(value: unknown): QuotaStatusScreen[] { + const requested = Array.isArray(value) ? value : [value]; + const screens: QuotaStatusScreen[] = []; + for (const entry of requested) { + const screen = QUOTA_STATUS_SCREENS.find((candidate) => candidate === entry); + if (screen && !screens.includes(screen)) screens.push(screen); + } + return screens.length > 0 ? screens : ["active"]; +} + +/** + * How the prompt status line describes the account pool. + * + * Every default here is the behaviour an install already has, so adding + * `"mode": "overview"` and nothing else changes the line's subject without + * changing anything about how it is written. The switches only apply to the + * pool screens; they are resolved unconditionally anyway so a reader of this + * config sees what `overview` would render without having to enable it first. + * + * Presentation preference belongs to a person rather than to a shell, so none + * of this is overridable by environment variable - the config file is the only + * place it is read from. + */ +export function getQuotaStatus(pluginConfig: PluginConfig): QuotaStatusConfig { + const config = pluginConfig.quotaStatus; + const resetTimes = config?.resetTimes; + const rotateMs = config?.rotateMs; + return { + screens: resolveQuotaStatusScreens(config?.mode), + rotateMs: + typeof rotateMs === "number" && Number.isFinite(rotateMs) + ? Math.max(MIN_QUOTA_STATUS_ROTATE_MS, rotateMs) + : DEFAULT_QUOTA_STATUS_ROTATE_MS, + // Per-account by default: someone switching to `overview` is asking + // where each account stands, and the count alone is the one form that + // does not answer that. + layout: + QUOTA_OVERVIEW_LAYOUTS.find((entry) => entry === config?.layout) ?? + (config?.accounts === false ? "count" : "accounts"), + accountNames: pickEnum( + config?.accountNames, + QUOTA_OVERVIEW_NAMES, + "number", + ), + order: pickEnum(config?.order, QUOTA_OVERVIEW_ORDERS, "number"), + multipliers: config?.multipliers ?? false, + allotment: config?.allotment ?? false, + // `low` by default: a spent account is the one an account list is read + // to find, and "when does it come back" is the next question every + // time, while the same countdown beside a healthy account is noise. + resetTimes: + typeof resetTimes === "boolean" + ? resetTimes + ? "low" + : "never" + : pickEnum(resetTimes, QUOTA_OVERVIEW_RESET_TIMES, "low"), + resetCredits: config?.resetCredits ?? false, + recovery: config?.recovery ?? false, + rows: + typeof config?.rows === "number" && Number.isFinite(config.rows) + ? Math.min(MAX_QUOTA_STATUS_ROWS, Math.max(1, Math.trunc(config.rows))) + : 1, + showFor: pickEnum(config?.showFor, QUOTA_STATUS_AUDIENCES, "always"), + }; +} diff --git a/lib/plan-allotment.ts b/lib/plan-allotment.ts new file mode 100644 index 00000000..a4d5fff8 --- /dev/null +++ b/lib/plan-allotment.ts @@ -0,0 +1,163 @@ +/** + * How much Codex capacity one ChatGPT plan carries relative to another. + * + * A pool of accounts on different plans has no single "percent used": one + * Pro seat spent to 50% has given up far more capacity than a Business + * Standard seat spent to 50%, so averaging the two percentages unweighted + * reports a pool that does not exist. Every plan here therefore carries a + * {@link PlanAllotment.weight} - its allotment relative to a 1x seat - and the + * pool total is the weighted mean. + * + * The weights are OpenAI's own published per-seat ratios, taken from the + * plan's monthly price against the 1x Plus/Business Standard seat: Pro is + * $200 against $20, and is marketed as 20x. They describe the *subscription*, + * not a measured token allowance, which is the only ratio OpenAI states and + * the same one the seat is sold on. + * + * `plan_type` is what the `/wham/usage` endpoint and the `chatgpt_plan_type` + * access-token claim report. Two of its slugs are not derivable from their + * text - `team` is the slug still emitted for what OpenAI now calls Business, + * and `self_serve_business_prolite` is the premium Business seat rather than + * the personal Pro Lite tier that shares the `prolite` token - so both are + * matched explicitly below. + * + * This module is deliberately a leaf (no imports). The prompt status line + * depends on it, and naming a plan is a separate concern that already has an + * owner in `lib/auth/plan-tier.ts`; pulling that in here would drag JWT + * decoding into the TUI's render path for a number that needs none of it. + */ + +export type PlanAllotment = { + /** + * Allotment relative to a 1x seat, or `undefined` when the plan states no + * ratio. `undefined` is not 1: a plan we cannot place must not be + * silently averaged as though it were the baseline. + */ + weight?: number; + /** Marketing badge for the same ratio, e.g. `5x`. */ + multiplier?: string; + /** Per-seat monthly price in USD, as listed by OpenAI. */ + monthlyUsd?: number; +}; + +const UNKNOWN_ALLOTMENT: PlanAllotment = {}; + +/** + * Weight used for a plan that states no ratio, so one unplaceable account + * cannot remove every other account from the pool total. It is the baseline + * seat rather than a guess at something larger: under-weighting an unknown + * plan understates one account, while over-weighting it would let a plan we + * failed to recognize dominate the number the whole pool is judged by. + */ +export const DEFAULT_PLAN_WEIGHT = 1; + +/** + * Reduce a `plan_type` to the form the matchers below expect. + * + * The admin roster spells a tier as one token (`chatgptteamplan`) while the + * usage endpoint reports the bare word (`team`), and the premium Business + * seat arrives underscored (`self_serve_business_prolite`). All three have to + * land on the same normalized text or one seat is weighted differently + * depending on which surface named it. + */ +export function normalizePlanSlug(value: string | null | undefined): string | undefined { + if (typeof value !== "string") return undefined; + // Case is folded before anything else is stripped, so every pattern below + // can be written once in lower case. Stripping first would leave the + // trailing-`plan` rule matching `teamplan` but not `Team_Plan`, and the + // same seat would then weigh differently depending on which surface spelled + // it. + const normalized = value + .trim() + .toLowerCase() + .replace(/^chatgpt[\s_-]*/, "") + .replace(/[\s_-]+/g, " ") + .replace(/\s?plan$/, "") + .trim(); + return normalized || undefined; +} + +/** + * A `business` seat's ratio, which depends on the qualifier beside it. + * + * `self_serve_business_prolite` normalizes to `self serve business prolite`, + * so `business` is matched as a whole word anywhere in the text rather than + * as a prefix. A bare `business` names the workspace rather than the seat, + * and the two seats inside it are 5x apart, so it states no ratio at all. + */ +function describeBusinessSeat(plan: string): PlanAllotment | undefined { + if (!/(^| )business( |$)/.test(plan)) return undefined; + if ( + plan.includes("prolite") || + plan.includes("pro lite") || + plan.includes("premium") + ) { + return { weight: 5, multiplier: "5x", monthlyUsd: 125 }; + } + if (plan.includes("standard")) { + return { weight: 1, multiplier: "1x", monthlyUsd: 25 }; + } + return UNKNOWN_ALLOTMENT; +} + +/** + * The $100 Pro that predates the $200 one. Both report a `pro` family slug, + * and they are 4x apart, so the legacy spellings are matched before the + * current tier claims the bare word. + */ +function isLegacyProPlan(plan: string): boolean { + return ( + plan === "pro 5x" || + plan === "pro 100" || + plan === "pro legacy" || + plan === "legacy pro" || + plan === "legacy pro 5x" || + plan === "pro legacy 5x" + ); +} + +function isCurrentProPlan(plan: string): boolean { + return plan === "pro" || plan === "pro 20x" || plan === "pro 200"; +} + +/** + * Resolve a `plan_type` to its allotment. An unrecognized plan returns an + * empty allotment rather than a guess, so callers can tell "1x" apart from + * "we do not know". + */ +export function describePlanAllotment( + planType: string | null | undefined, +): PlanAllotment { + const plan = normalizePlanSlug(planType); + if (!plan) return UNKNOWN_ALLOTMENT; + if (plan === "plus") return { weight: 1, multiplier: "1x", monthlyUsd: 20 }; + // `team` is the slug OpenAI still emits for Business; a qualified + // `team premium` / `team standard` is handled by the business matcher. + if (plan === "team") return { weight: 1, multiplier: "1x", monthlyUsd: 25 }; + const business = describeBusinessSeat(plan); + if (business) return business; + if (isLegacyProPlan(plan)) return { weight: 5, multiplier: "5x", monthlyUsd: 100 }; + if (isCurrentProPlan(plan)) return { weight: 20, multiplier: "20x", monthlyUsd: 200 }; + if (plan === "prolite" || plan === "pro lite") { + return { weight: 5, multiplier: "5x", monthlyUsd: 100 }; + } + // Go, Free and Enterprise all reach here. The first two carry no Codex + // allotment worth weighting, and Enterprise is negotiated per contract, so + // none of them states a ratio this code could apply. + return UNKNOWN_ALLOTMENT; +} + +/** + * The weight to average an account by, falling back to + * {@link DEFAULT_PLAN_WEIGHT} for a plan that states no ratio. + */ +export function getPlanWeight(planType: string | null | undefined): number { + return describePlanAllotment(planType).weight ?? DEFAULT_PLAN_WEIGHT; +} + +/** `5x`, or `undefined` when the plan states no ratio. */ +export function formatPlanMultiplier( + planType: string | null | undefined, +): string | undefined { + return describePlanAllotment(planType).multiplier; +} diff --git a/lib/quota-display.ts b/lib/quota-display.ts new file mode 100644 index 00000000..7061da76 --- /dev/null +++ b/lib/quota-display.ts @@ -0,0 +1,62 @@ +/** + * How a quota percentage is worded on the surfaces a person reads. + * + * Codex reports the headroom an account still has, so `free` is the default + * and the wording every surface here already used. `used` inverts it for + * people who track consumption rather than headroom. + * + * This governs presentation only. Exhaustion, rotation blocks, notification + * thresholds and the warning/danger colouring all stay keyed on the remaining + * percentage, because those decisions are about headroom regardless of how the + * number is worded. + * + * This module is deliberately a leaf (no imports): the prompt status line + * (`lib/tui-status.ts`) and the usage surfaces (`lib/codex-usage.ts`) both + * depend on it, and it must not drag either into the other. + */ + +export type QuotaDisplayMode = "free" | "used"; + +export const QUOTA_DISPLAY_MODES: readonly QuotaDisplayMode[] = ["free", "used"]; + +export const DEFAULT_QUOTA_DISPLAY_MODE: QuotaDisplayMode = "free"; + +/** + * The number to print for a window, given the percentage still free. + * + * The used figure is derived from the free one rather than from the raw + * `used_percent` the backend sent, so the two readings of one window always + * add up to 100. Rounding them independently would let the same window render + * as `88% left` on one surface and `13% used` on another in one session. + */ +export function toQuotaDisplayPercent( + leftPercent: number, + mode: QuotaDisplayMode, +): number { + return mode === "used" ? 100 - leftPercent : leftPercent; +} + +/** + * `72%` — for surfaces with no room to name what the number counts. + * + * The compact prompt status line and the desktop notification both print a + * bare percentage today, and they keep doing so in either mode: their budget + * is spent on the account hint and the reset time, and the mode is an explicit + * opt-in rather than something a reader has to infer per line. + */ +export function formatQuotaPercent( + leftPercent: number, + mode: QuotaDisplayMode, +): string { + return `${toQuotaDisplayPercent(leftPercent, mode)}%`; +} + +/** `72% left` / `28% used` — wherever there is room to say which it is. */ +export function formatNamedQuotaPercent( + leftPercent: number, + mode: QuotaDisplayMode, +): string { + return `${formatQuotaPercent(leftPercent, mode)} ${ + mode === "used" ? "used" : "left" + }`; +} diff --git a/lib/quota-notifications.ts b/lib/quota-notifications.ts index 64b62a6a..e0fc392b 100644 --- a/lib/quota-notifications.ts +++ b/lib/quota-notifications.ts @@ -1,4 +1,14 @@ -import { getQuotaNotifications, loadPluginConfig, type QuotaNotificationsConfig } from "./config.js"; +import { + getQuotaDisplay, + getQuotaNotifications, + loadPluginConfig, + type QuotaNotificationsConfig, +} from "./config.js"; +import { + DEFAULT_QUOTA_DISPLAY_MODE, + formatNamedQuotaPercent, + type QuotaDisplayMode, +} from "./quota-display.js"; import { deduplicateUsageAccountIndices, ensureCodexUsageAccessToken, @@ -247,16 +257,26 @@ export function transitionQuotaState( * account; the pool's earlier reset, when there is one, gets its own clause so * nothing reads as "this percentage recovers then". */ -function formatQuotaWindow(label: string, window: AggregatedQuotaWindow): string { +function formatQuotaWindow( + label: string, + window: AggregatedQuotaWindow, + mode: QuotaDisplayMode, +): string { if (window.remainingPercent === undefined) return `${label}: unavailable`; const reset = formatUsageReset(window.resetAtMs) ?? "unavailable"; const earliest = formatUsageReset(window.earliestResetAtMs); const poolClause = earliest ? ` | another account resets ${earliest}` : ""; - return `${label}: ${window.remainingPercent}% | resets ${reset}${poolClause}`; + // Named, not bare: a notification is read without the line's context, so + // `80%` alone cannot say whether it is headroom or consumption. + const percent = formatNamedQuotaPercent(window.remainingPercent, mode); + return `${label}: ${percent} | resets ${reset}${poolClause}`; } -export function formatQuotaNotification(usage: AggregatedQuotaUsage): string { - return `${formatQuotaWindow("5h", usage.fiveHour)}\n${formatQuotaWindow("Weekly", usage.weekly)}`; +export function formatQuotaNotification( + usage: AggregatedQuotaUsage, + mode: QuotaDisplayMode = DEFAULT_QUOTA_DISPLAY_MODE, +): string { + return `${formatQuotaWindow("5h", usage.fiveHour, mode)}\n${formatQuotaWindow("Weekly", usage.weekly, mode)}`; } interface DeliveryClaim { @@ -435,7 +455,10 @@ export function createQuotaMonitor(overrides: Partial = {}) disposed || expectedGeneration !== generation ? false : await dependencies - .notify("Codex quota status", formatQuotaNotification(usage)) + .notify( + "Codex quota status", + formatQuotaNotification(usage, getQuotaDisplay(loadPluginConfig())), + ) .catch((error: unknown) => { logDebug(`Failed to deliver quota notification: ${(error as Error).message}`); return false; diff --git a/lib/quota-overview.ts b/lib/quota-overview.ts new file mode 100644 index 00000000..9402cddc --- /dev/null +++ b/lib/quota-overview.ts @@ -0,0 +1,889 @@ +/** + * One constant line describing the whole account pool. + * + * The prompt status line names whichever account served the most recent + * request, so on a pool of several accounts it changes identity as rotation + * moves - and a reader who wants to know where the pool stands has to watch it + * long enough to see every account go past. This module renders the pool + * instead: every account at once, in a fixed order, so the line only changes + * when the underlying quota does. + * + * ```text + * 24%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12% + * ``` + * + * The leading figure is the pool total, and it is a WEIGHTED mean rather than + * a plain one. A Pro seat spent to 50% has given up twenty times the capacity + * a Business Standard seat does at 50%, so averaging the percentages + * unweighted describes a pool nobody has; `lib/plan-allotment.ts` supplies the + * per-plan ratio the mean is taken over. + * + * Percentages follow `quotaDisplay` like every other surface, so the same pool + * reads `24%` as headroom or `76%` as consumption. Only the wording changes: + * every decision here - which window governs an account, which account is + * closest to recovering, whether a reset time is worth the characters - stays + * keyed on the percentage remaining. + * + * Everything below is pure string work over already-gathered readings, so the + * whole rendering can be exercised without a network, a clock or a terminal. + */ + +import { maskEmailForDisplay } from "./account-display.js"; +import { formatPlanMultiplier, getPlanWeight } from "./plan-allotment.js"; +import { + formatQuotaPercent, + toQuotaDisplayPercent, + type QuotaDisplayMode, +} from "./quota-display.js"; + +const MS_PER_MINUTE = 60_000; +const MS_PER_HOUR = 60 * MS_PER_MINUTE; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +/** + * Under `resetTimes: "low"`, only an account at or below this headroom gets + * its reset time printed. + * + * Every account has a reset, and printing all of them triples the length of + * the line to say "this account you are not waiting on recovers at some point + * too". The threshold matches the one the single-account status line already + * uses to decide the same question, so an account near exhaustion reads the + * same way in either mode. `resetTimes: "always"` opts out of the threshold, + * because 90% spent with an hour to go and 90% spent with six days to go are + * not the same situation. + */ +export const OVERVIEW_RESET_LEFT_PERCENT = 25; + +/** Smallest pool-total movement worth spending characters on. */ +const MIN_RECOVERY_DELTA_PERCENT = 1; + +export type QuotaOverviewWindow = { + /** Percentage of this window still free, 0-100. */ + leftPercent?: number; + resetAtMs?: number; +}; + +export type QuotaOverviewAccount = { + /** 1-based position, as `codex-list` and `codex-switch` number accounts. */ + index: number; + /** + * The account's own name for itself: a `codex-label` label when one is + * set, otherwise whatever identity the account storage carries. May be an + * email address, which is why every rendering of it goes through + * {@link resolveAccountName} rather than printing it directly. + */ + label?: string; + /** ChatGPT email, used by the reset-credit line and as a label fallback. */ + email?: string; + /** `plan_type` as reported by `/wham/usage`, used for the weighting only. */ + planType?: string; + windows: readonly QuotaOverviewWindow[]; + /** Banked rate-limit resets redeemable now, rendered as `1r`. */ + resetCredits?: number; +}; + +/** How the accounts are arranged on the line. */ +export type QuotaOverviewLayout = + /** One segment per account: `#1 13%, #2 100% 3d`. */ + | "accounts" + /** Accounts sharing a percentage collapse: `100% 3d 4d 5d`. */ + | "aggregate" + /** No accounts at all, just how many there are: `3 accounts`. */ + | "count"; + +/** What identifies an account on the line. */ +export type QuotaOverviewNames = + /** `#1`, the number `codex-switch` takes. */ + | "number" + /** The account's label, or its email's local part: `damian`, `work`. */ + | "label" + /** Nothing; the accounts are told apart by position alone. */ + | "none"; + +/** The order accounts appear in. */ +export type QuotaOverviewOrder = + | "number" + /** Least headroom first - the accounts rotation is about to stop using. */ + | "most-used" + /** Most headroom first - the accounts with work left in them. */ + | "least-used" + /** Soonest reset first. Accounts with no known reset sort last. */ + | "renewing-earliest" + /** Latest reset first, which is redemption order for a banked reset. */ + | "renewing-latest"; + +/** Which accounts get a reset countdown printed beside them. */ +export type QuotaOverviewResetTimes = + | "never" + /** Only accounts at or below {@link OVERVIEW_RESET_LEFT_PERCENT}. */ + | "low" + | "always"; + +export type QuotaOverviewOptions = { + mode: QuotaDisplayMode; + layout: QuotaOverviewLayout; + names: QuotaOverviewNames; + order: QuotaOverviewOrder; + /** `5x` / `20x` allotment badges beside each account. */ + multipliers: boolean; + /** `66% of 65x`: what the pool the percentage is taken over adds up to. */ + allotment: boolean; + resetTimes: QuotaOverviewResetTimes; + /** `1r` for redeemable banked resets. */ + resetCredits: boolean; + /** `+12% in 3d`: how far the pool total moves at the next reset. */ + recovery: boolean; + /** Masks any email this line would otherwise print in full. */ + maskEmail?: boolean; + now?: number; +}; + +export type QuotaOverviewRecovery = { + /** + * Pool-total movement at {@link atMs}, in percentage points, always + * positive - it is capacity returning. The rendered sign follows the + * display mode, since the number a reader is watching moves up under + * `free` and down under `used`. + */ + deltaPercent: number; + atMs: number; +}; + +function isPercent(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +/** + * Render a duration the way a countdown reads: the largest unit that fits, + * floored, so `2d` never claims more time remains than actually does. A gap + * under a minute still reads `1m` rather than `0m`, because a reset that has + * not happened yet is not zero away. + */ +export function formatCompactDuration(ms: number): string | undefined { + if (!Number.isFinite(ms) || ms <= 0) return undefined; + if (ms >= MS_PER_DAY) return `${Math.floor(ms / MS_PER_DAY)}d`; + if (ms >= MS_PER_HOUR) return `${Math.floor(ms / MS_PER_HOUR)}h`; + return `${Math.max(1, Math.floor(ms / MS_PER_MINUTE))}m`; +} + +/** + * The window that decides what an account can still do. + * + * An account reports several windows at once - typically a 5-hour and a weekly + * one - and the one with the least headroom is the one that stops a request, + * so it is the one the account is described by. On a tie the window that + * blocks for longer governs: two windows both fully spent are not equally + * costly when one returns in four hours and the other in three days. + */ +export function resolveGoverningWindow( + account: QuotaOverviewAccount, +): QuotaOverviewWindow | undefined { + let governing: QuotaOverviewWindow | undefined; + for (const window of account.windows) { + if (!isPercent(window.leftPercent)) continue; + if (!governing) { + governing = window; + continue; + } + const governingLeft = governing.leftPercent ?? 100; + if (window.leftPercent < governingLeft) { + governing = window; + continue; + } + if ( + window.leftPercent === governingLeft && + isPercent(window.resetAtMs) && + (!isPercent(governing.resetAtMs) || window.resetAtMs > governing.resetAtMs) + ) { + governing = window; + } + } + return governing; +} + +/** + * Weighted mean headroom across the pool, or `undefined` when no account + * reported a readable window. + * + * Accounts with no readable window are left out rather than counted as full: + * a quota we could not read is not capacity we know we have. + */ +export function computeWeightedLeftPercent( + accounts: readonly QuotaOverviewAccount[], +): number | undefined { + let weighted = 0; + let totalWeight = 0; + for (const account of accounts) { + const governing = resolveGoverningWindow(account); + if (!governing || !isPercent(governing.leftPercent)) continue; + const weight = getPlanWeight(account.planType); + if (!Number.isFinite(weight) || weight <= 0) continue; + weighted += weight * governing.leftPercent; + totalWeight += weight; + } + return totalWeight > 0 ? Math.round(weighted / totalWeight) : undefined; +} + +/** + * What the pool the percentage is taken over adds up to, in 1x seats. + * + * Deliberately the same sum {@link computeWeightedLeftPercent} divides by, and + * over the same accounts, so `66% of 65x` is one statement rather than two + * that can disagree. An account whose plan states no ratio therefore + * contributes its fallback weight here exactly as it does to the mean. + */ +export function computePoolAllotment( + accounts: readonly QuotaOverviewAccount[], +): number | undefined { + let total = 0; + for (const account of accounts) { + const governing = resolveGoverningWindow(account); + if (!governing || !isPercent(governing.leftPercent)) continue; + const weight = getPlanWeight(account.planType); + if (!Number.isFinite(weight) || weight <= 0) continue; + total += weight; + } + return total > 0 ? total : undefined; +} + +/** + * The next moment the pool gets capacity back, and how much. + * + * Only the window that actually resets is refilled, and the account's + * governing window is then resolved again: an account whose 5-hour window + * resets while its weekly window is still spent gains nothing, and reporting + * the 5-hour refill as pool recovery would promise headroom that does not + * arrive. Movement below one point is dropped rather than rendered as `+0%`. + */ +export function resolveQuotaOverviewRecovery( + accounts: readonly QuotaOverviewAccount[], + now: number = Date.now(), +): QuotaOverviewRecovery | undefined { + const current = computeWeightedLeftPercent(accounts); + if (current === undefined) return undefined; + + let earliest: number | undefined; + for (const account of accounts) { + for (const window of account.windows) { + if (!isPercent(window.leftPercent) || window.leftPercent >= 100) continue; + const resetAtMs = window.resetAtMs; + if (!isPercent(resetAtMs) || resetAtMs <= now) continue; + if (earliest === undefined || resetAtMs < earliest) earliest = resetAtMs; + } + } + if (earliest === undefined) return undefined; + + const refilled = accounts.map((account) => ({ + ...account, + windows: account.windows.map((window) => + isPercent(window.resetAtMs) && window.resetAtMs <= earliest + ? { ...window, leftPercent: 100 } + : window, + ), + })); + const recovered = computeWeightedLeftPercent(refilled); + if (recovered === undefined) return undefined; + const deltaPercent = recovered - current; + if (deltaPercent < MIN_RECOVERY_DELTA_PERCENT) return undefined; + return { deltaPercent, atMs: earliest }; +} + +/** + * Whether nothing in the pool has capacity left. + * + * This is the condition the reset-credit line exists for: while any account + * can still serve a request, which one recovers when is a detail, and once + * none can it is the only question left. Accounts whose quota could not be + * read do not count either way - an unknown reading is not evidence of + * exhaustion, but it is not capacity either, so a pool of nothing but + * unreadable accounts is reported as not spent rather than as dead. + */ +export function isPoolFullySpent( + accounts: readonly QuotaOverviewAccount[], +): boolean { + let readable = 0; + for (const account of accounts) { + const governing = resolveGoverningWindow(account); + if (!governing || !isPercent(governing.leftPercent)) continue; + readable += 1; + if (governing.leftPercent > 0) return false; + } + return readable > 0; +} + +/** + * Sort key for the reset-time orders. + * + * An account with no known reset sorts after every account that has one, in + * both directions. Not knowing when something returns is a different statement + * from knowing it returns soon, and a different statement from knowing it + * returns last. + */ +function governingResetAtMs( + account: QuotaOverviewAccount, +): number | undefined { + const governing = resolveGoverningWindow(account); + return governing && isPercent(governing.resetAtMs) + ? governing.resetAtMs + : undefined; +} + +function governingLeftPercent( + account: QuotaOverviewAccount, +): number | undefined { + const governing = resolveGoverningWindow(account); + return governing && isPercent(governing.leftPercent) + ? governing.leftPercent + : undefined; +} + +/** + * Arrange the accounts for display. + * + * Every comparison falls back to the account number, so two accounts reading + * the same percentage never trade places between renders. An order that let + * them would reintroduce exactly the movement this mode exists to remove. + */ +export function orderOverviewAccounts( + accounts: readonly QuotaOverviewAccount[], + order: QuotaOverviewOrder, +): QuotaOverviewAccount[] { + const sorted = [...accounts]; + if (order === "most-used" || order === "least-used") { + const direction = order === "most-used" ? 1 : -1; + return sorted.sort((left, right) => { + const leftPercent = governingLeftPercent(left); + const rightPercent = governingLeftPercent(right); + if (leftPercent === undefined && rightPercent === undefined) { + return left.index - right.index; + } + if (leftPercent === undefined) return 1; + if (rightPercent === undefined) return -1; + if (leftPercent !== rightPercent) { + return direction * (leftPercent - rightPercent); + } + return left.index - right.index; + }); + } + if (order === "renewing-earliest" || order === "renewing-latest") { + const direction = order === "renewing-earliest" ? 1 : -1; + return sorted.sort((left, right) => { + const leftReset = governingResetAtMs(left); + const rightReset = governingResetAtMs(right); + if (leftReset === undefined && rightReset === undefined) { + return left.index - right.index; + } + if (leftReset === undefined) return 1; + if (rightReset === undefined) return -1; + if (leftReset !== rightReset) return direction * (leftReset - rightReset); + return left.index - right.index; + }); + } + // `number` and anything unrecognized: an order nobody asked for must not + // silently become one of the sorted ones, which would move accounts around + // under a reader who configured nothing. + return sorted.sort((left, right) => left.index - right.index); +} + +const EMAIL_LIKE = /^[^\s@]+@[^\s@]+$/; + +/** + * The local part of an email, which is what a person calls the account. + * + * `damian@nowaker.net` -> `damian`. Masking is applied to the local part + * rather than to the whole address, because the domain is what + * {@link maskEmailForDisplay} keeps and there is no room for it here. + */ +function formatEmailLocalPart( + email: string, + maskEmail: boolean, +): string | undefined { + const trimmed = email.trim(); + if (!trimmed) return undefined; + const local = trimmed.split("@")[0]?.trim(); + if (!local) return undefined; + if (!maskEmail) return local; + return `${Array.from(local).slice(0, 2).join("")}***`; +} + +/** + * What this account is called on the line. + * + * Under `label` a user-set label wins, because it is the one name the user + * chose; an account that only knows its email falls back to that email's local + * part, and an account with neither falls back to its number rather than + * rendering nothing - a nameless segment in a named line reads as a missing + * account. + */ +export function resolveAccountName( + account: QuotaOverviewAccount, + names: QuotaOverviewNames, + maskEmail = false, +): string | undefined { + if (names === "none") return undefined; + if (names === "number") return `#${account.index}`; + const label = account.label?.trim(); + if (label && !EMAIL_LIKE.test(label)) return label; + const email = label && EMAIL_LIKE.test(label) ? label : account.email; + const local = email ? formatEmailLocalPart(email, maskEmail) : undefined; + return local ?? `#${account.index}`; +} + +/** The full email for the reset-credit line, masked when asked. */ +function resolveAccountEmail( + account: QuotaOverviewAccount, + maskEmail: boolean, +): string | undefined { + const label = account.label?.trim(); + const email = + account.email?.trim() || (label && EMAIL_LIKE.test(label) ? label : undefined); + if (!email) return undefined; + return maskEmail ? maskEmailForDisplay(email) : email; +} + +function resolveResetCredits(account: QuotaOverviewAccount): number { + const credits = account.resetCredits; + return typeof credits === "number" && Number.isFinite(credits) && credits > 0 + ? Math.trunc(credits) + : 0; +} + +/** How much of an account's segment is annotation rather than percentage. */ +type AnnotationRung = { + names: QuotaOverviewNames; + multipliers: boolean; + resetTimes: QuotaOverviewResetTimes; + resetCredits: boolean; +}; + +type SegmentOptions = AnnotationRung & { + mode: QuotaDisplayMode; + maskEmail: boolean; + now: number; +}; + +function shouldPrintReset( + leftPercent: number, + resetTimes: QuotaOverviewResetTimes, +): boolean { + if (resetTimes === "never") return false; + if (resetTimes === "always") return true; + return leftPercent <= OVERVIEW_RESET_LEFT_PERCENT; +} + +/** The part of a segment that is not the account's name or its percentage. */ +function formatAccountAnnotations( + account: QuotaOverviewAccount, + governing: QuotaOverviewWindow, + options: SegmentOptions, +): string[] { + const parts: string[] = []; + const leftPercent = governing.leftPercent; + if ( + isPercent(leftPercent) && + shouldPrintReset(leftPercent, options.resetTimes) && + isPercent(governing.resetAtMs) + ) { + const reset = formatCompactDuration(governing.resetAtMs - options.now); + if (reset) parts.push(reset); + } + if (options.resetCredits) { + const credits = resolveResetCredits(account); + if (credits > 0) parts.push(`${credits}r`); + } + return parts; +} + +function formatAccountSegment( + account: QuotaOverviewAccount, + options: SegmentOptions, +): string | undefined { + const governing = resolveGoverningWindow(account); + if (!governing || !isPercent(governing.leftPercent)) return undefined; + const parts: string[] = []; + const name = resolveAccountName(account, options.names, options.maskEmail); + if (name) parts.push(name); + if (options.multipliers) { + const multiplier = formatPlanMultiplier(account.planType); + if (multiplier) parts.push(multiplier); + } + parts.push(formatQuotaPercent(governing.leftPercent, options.mode)); + parts.push(...formatAccountAnnotations(account, governing, options)); + return parts.join(" "); +} + +/** + * Accounts reading the same percentage, collapsed into one segment. + * + * On a pool where several accounts are fully spent, `100% 3d, 100% 4d, + * 100% 5d` spends two thirds of its characters repeating a number that is the + * same every time. Grouping prints it once and keeps what differs: + * + * ```text + * 100% 3d 1r 4d 5d + * ``` + * + * The group's size is stated explicitly - `100% x3 3d` - only when the + * annotations do not already reveal it, so a group of three accounts where one + * has no reset time to print cannot read as a group of two. A group of one is + * never counted, because there is nothing to count. + * + * Grouping discards identity by construction, so `names` has no effect here. + */ +function formatAggregateSegments( + accounts: readonly QuotaOverviewAccount[], + options: SegmentOptions, +): string[] { + const groups = new Map< + string, + { percent: string; size: number; annotations: string[] } + >(); + for (const account of accounts) { + const governing = resolveGoverningWindow(account); + if (!governing || !isPercent(governing.leftPercent)) continue; + const percent = formatQuotaPercent(governing.leftPercent, options.mode); + const group = groups.get(percent) ?? { + percent, + size: 0, + annotations: [], + }; + group.size += 1; + const annotations = formatAccountAnnotations(account, governing, options); + if (annotations.length > 0) group.annotations.push(annotations.join(" ")); + groups.set(percent, group); + } + return [...groups.values()].map((group) => { + const parts = [group.percent]; + if (group.size > 1 && group.annotations.length < group.size) { + parts.push(`x${group.size}`); + } + parts.push(...group.annotations); + return parts.join(" "); + }); +} + +/** + * `+12% in 3d` / `-12% 3d`. + * + * The sign describes the direction the number beside it moves, not the + * direction of the user's fortunes: under `used` the pool total falls as + * capacity returns, and a `+` there would contradict the figure it annotates. + * The word `in` is the first thing dropped when the line is short, because it + * is the only part of the clause a reader can supply themselves. + */ +function formatRecovery( + recovery: QuotaOverviewRecovery, + options: { mode: QuotaDisplayMode; now: number; words: boolean }, +): string | undefined { + const at = formatCompactDuration(recovery.atMs - options.now); + if (!at) return undefined; + const sign = options.mode === "used" ? "-" : "+"; + return `${sign}${recovery.deltaPercent}% ${options.words ? "in " : ""}${at}`; +} + +/** `3 accounts` -> `3 acct.` -> `3`, in the order they are given up. */ +export type QuotaOverviewCountStyle = "long" | "short" | "bare"; + +function formatAccountCount( + count: number, + style: QuotaOverviewCountStyle, +): string { + if (style === "bare") return `${count}`; + if (style === "short") return `${count} acct.`; + return `${count} account${count === 1 ? "" : "s"}`; +} + +/** + * Every annotation level to try, most informative first. + * + * One dimension is given up per rung and never restored within the ladder, so + * each rung is strictly shorter than the one above it. The order is by what a + * reader loses: the plan badge says nothing the account's own numbers do not, + * a long label can be replaced by the number that selects the same account, + * banked resets only matter once something is spent, and a reset countdown on + * a healthy account is the detail `resetTimes: "always"` opted into. + * + * Dropping the account's name entirely is the last rung, and it is offered + * only when position still identifies an account: under a sorted order, or + * with any account missing from the line, `39%, 8%, 91%` names nothing at all + * and a reader would attach those figures to the wrong seats. + */ +function annotationRungs( + options: QuotaOverviewOptions, + positionsAreComplete: boolean, +): AnnotationRung[] { + const rungs: AnnotationRung[] = []; + let current: AnnotationRung = { + names: options.names, + multipliers: options.multipliers, + resetTimes: options.resetTimes, + resetCredits: options.resetCredits, + }; + rungs.push(current); + const step = (next: Partial): void => { + current = { ...current, ...next }; + rungs.push(current); + }; + if (current.multipliers) step({ multipliers: false }); + if (current.names === "label") step({ names: "number" }); + if (current.resetCredits) step({ resetCredits: false }); + if (current.resetTimes === "always") step({ resetTimes: "low" }); + if (current.resetTimes !== "never") step({ resetTimes: "never" }); + if (current.names !== "none" && positionsAreComplete) step({ names: "none" }); + return rungs; +} + +/** + * Every rendering of this pool, longest first. + * + * The caller takes the first that fits its width. Detail is dropped in the + * order that costs a reader the least: the recovery clause and then the + * annotations (badges, banked resets) that sit beside a figure which stays + * either way, then the per-account breakdown, leaving the pool total - the one + * thing the line exists to say - as the last to go. + * + * Order is preference, NOT length: a stripped-down rung is occasionally a + * character or two longer than the rung above it. Sorting by length instead + * would let a form win or lose by two characters as a percentage crosses from + * `9%` to `10%`, and the line would change shape while the reader watches - + * the flicker this whole mode exists to remove. + * + * Candidates never exceed what {@link QuotaOverviewOptions} asked for, so a + * switch left off cannot reappear because the terminal happened to be wide. + */ +export function formatQuotaOverviewCandidates( + accounts: readonly QuotaOverviewAccount[], + options: QuotaOverviewOptions, +): string[] { + const now = options.now ?? Date.now(); + const maskEmail = options.maskEmail ?? false; + const total = computeWeightedLeftPercent(accounts); + if (total === undefined) return []; + const totalText = formatQuotaPercent(total, options.mode); + + const ordered = orderOverviewAccounts(accounts, options.order); + const usable = ordered.filter((account) => resolveGoverningWindow(account)); + const recovery = options.recovery + ? resolveQuotaOverviewRecovery(accounts, now) + : undefined; + const recoveryForms = recovery + ? [true, false] + .map((words) => + formatRecovery(recovery, { mode: options.mode, now, words }), + ) + .filter((form): form is string => Boolean(form)) + : []; + + const bodies: string[] = []; + if (options.layout !== "count") { + // Position identifies an account only when the accounts are in number + // order, none of them is missing from the line, and the numbers run + // 1..n with no gap - a deduplicated or disabled account leaves indices + // like #1, #3, where the second percentage is NOT account #2's. + const positionsAreComplete = + options.order === "number" && + usable.length === accounts.length && + usable.every((account, position) => account.index === position + 1); + for (const rung of annotationRungs(options, positionsAreComplete)) { + const segmentOptions: SegmentOptions = { + ...rung, + mode: options.mode, + maskEmail, + now, + }; + const segments = + options.layout === "aggregate" + ? formatAggregateSegments(usable, segmentOptions) + : usable + .map((account) => formatAccountSegment(account, segmentOptions)) + .filter((segment): segment is string => Boolean(segment)); + if (segments.length === 0) continue; + const text = segments.join(", "); + if (!bodies.includes(text)) bodies.push(text); + } + } + + // `66% of 65x` before `66%`: the allotment is a small, near-static + // annotation, so it is given up early - but not before any account detail, + // which is what the line is read for. + const heads: string[] = []; + if (options.allotment) { + const allotment = computePoolAllotment(accounts); + if (allotment !== undefined) heads.push(`${totalText} of ${allotment}x`); + } + if (!heads.includes(totalText)) heads.push(totalText); + + const candidates: string[] = []; + const push = (head: string, ...tail: Array): void => { + const body = tail.filter((part): part is string => Boolean(part)); + const text = body.length > 0 ? `${head}: ${body.join(", ")}` : head; + if (!candidates.includes(text)) candidates.push(text); + }; + + for (const body of bodies) { + for (const head of heads) { + for (const form of recoveryForms) push(head, body, form); + push(head, body); + } + } + for (const head of heads) { + // The count word is grammar, the recovery clause is information, so the + // word goes first. The count is the pool's size, not the number of + // accounts that could be read - `40%: 1 account` on a three-account + // pool would say a pool exists that does not. + if (recoveryForms.length > 0) { + for (const form of recoveryForms) push(head, formatAccountCount(accounts.length, "long"), form); + const shortest = recoveryForms[recoveryForms.length - 1]; + push(head, formatAccountCount(accounts.length, "short"), shortest); + push(head, formatAccountCount(accounts.length, "bare"), shortest); + } + push(head, formatAccountCount(accounts.length, "long")); + push(head, formatAccountCount(accounts.length, "short")); + push(head, formatAccountCount(accounts.length, "bare")); + } + for (const head of heads) push(head); + return candidates; +} + +/** The fullest rendering, for surfaces with a line to themselves. */ +export function formatQuotaOverviewText( + accounts: readonly QuotaOverviewAccount[], + options: QuotaOverviewOptions, +): string { + return formatQuotaOverviewCandidates(accounts, options)[0] ?? ""; +} + +/** + * Every rendering of the banked reset credits, longest first. + * + * ```text + * Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com + * ``` + * + * Sorted by the LATEST reset first, which is redemption order rather than + * reading order: redeeming a credit on an account that renews by itself + * tomorrow throws the credit away, while the account six days out is the one + * worth spending it on. + * + * Returns nothing at all unless the pool is spent and something is redeemable. + * A reset credit is only an answer to "everything is used up"; offered while + * accounts still have headroom it is an invitation to waste it. + */ +export function formatQuotaResetsCandidates( + accounts: readonly QuotaOverviewAccount[], + options: Pick & { + names?: QuotaOverviewNames; + now?: number; + }, +): string[] { + if (!isPoolFullySpent(accounts)) return []; + const now = options.now ?? Date.now(); + const maskEmail = options.maskEmail ?? false; + const redeemable = orderOverviewAccounts(accounts, "renewing-latest").filter( + (account) => resolveResetCredits(account) > 0, + ); + if (redeemable.length === 0) return []; + + const durations = redeemable.map((account) => { + const resetAtMs = governingResetAtMs(account); + return resetAtMs === undefined + ? undefined + : formatCompactDuration(resetAtMs - now); + }); + const credits = redeemable.map((account) => resolveResetCredits(account)); + const everyAccountHasOneCredit = credits.every((count) => count === 1); + + // The identity follows `accountNames` like the pool line does: the full + // address is the longest form of the `label` name and appears only there, + // `number` gives `#n`, and `none` names no account at all. + const names = options.names ?? "label"; + const unnamed = redeemable.map(() => undefined); + const identities: Array> = + names === "none" + ? [unnamed] + : names === "number" + ? [ + redeemable.map((account) => + resolveAccountName(account, "number", maskEmail), + ), + ] + : [ + redeemable.map((account) => + resolveAccountEmail(account, maskEmail), + ), + redeemable.map((account) => + resolveAccountName(account, "label", maskEmail), + ), + redeemable.map((account) => + resolveAccountName(account, "number", maskEmail), + ), + ]; + + const candidates: string[] = []; + const add = (text: string): void => { + if (!candidates.includes(text)) candidates.push(text); + }; + const push = ( + prefix: string, + identity: Array, + withDuration: boolean, + withCredits: boolean, + ): void => { + const segments = redeemable.map((_account, position) => { + const parts: string[] = []; + const duration = durations[position]; + if (withDuration && duration) parts.push(duration); + if (withCredits) parts.push(`${credits[position]}r`); + const name = identity[position]; + if (name) parts.push(name); + return parts.join(" "); + }); + // A form that would render one account as an empty segment is not a + // shorter rendering of this line, it is a different and wrong one. + if (segments.some((segment) => segment.length === 0)) return; + add(`${prefix} ${segments.join(", ")}`); + }; + + // The word `Free` is given up before any account detail is, and the + // identity then shortens from the fullest configured form - the full + // address under `label` - down to the number `codex-reset` takes. + push("Free resets:", identities[0] ?? unnamed, true, true); + for (const identity of identities) push("Resets:", identity, true, true); + // Only once every identity form has been tried does the line start giving + // up facts: the countdown is the reason one account is a better redemption + // than another, and the credit count stops being news when every account + // holds exactly one. + for (const identity of identities) { + if (everyAccountHasOneCredit) push("Resets:", identity, true, false); + push("Resets:", identity, false, true); + push("Resets:", identity, false, false); + } + add(`Resets: ${redeemable.length}`); + return candidates; +} + +/** + * Headroom of the account with the most room left, for the caller that + * colours the line. + * + * Deliberately the best account rather than the worst: a pool is only in + * trouble when nothing in it has room left, and keying on the worst account + * would paint the line red for one spent seat that rotation has already + * stopped selecting while every other account serves requests normally. + */ +export function resolveQuotaOverviewTonePercent( + accounts: readonly QuotaOverviewAccount[], +): number | undefined { + let best: number | undefined; + for (const account of accounts) { + const governing = resolveGoverningWindow(account); + if (!governing || !isPercent(governing.leftPercent)) continue; + if (best === undefined || governing.leftPercent > best) { + best = governing.leftPercent; + } + } + return best; +} + +/** Re-exported so callers rendering a bare total need only this module. */ +export { toQuotaDisplayPercent }; diff --git a/lib/schemas.ts b/lib/schemas.ts index be80e5a9..01e02440 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -10,6 +10,8 @@ import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; // Plugin Configuration Schema // ============================================================================ +export const QuotaStatusScreenSchema = z.enum(["active", "overview", "resets"]); + export const PluginConfigSchema = z.object({ codexMode: z.boolean().optional(), requestTransformMode: z.enum(["native", "legacy"]).optional(), @@ -18,6 +20,7 @@ export const PluginConfigSchema = z.object({ codexTuiGlyphMode: z.enum(["ascii", "unicode", "auto"]).optional(), maskEmail: z.boolean().optional(), maskEmailInQuotaDetails: z.boolean().optional(), + quotaDisplay: z.enum(["free", "used"]).optional(), beginnerSafeMode: z.boolean().optional(), fastSession: z.boolean().optional(), fastSessionStrategy: z.enum(["hybrid", "always"]).optional(), @@ -74,6 +77,37 @@ export const PluginConfigSchema = z.object({ notifyEveryCheck: z.boolean().optional(), thresholds: z.array(z.number().min(0).max(100)).optional(), }).optional(), + quotaStatus: z.object({ + mode: z.union([ + QuotaStatusScreenSchema, + z.array(QuotaStatusScreenSchema), + ]).optional(), + rotateMs: z.number().min(1_000).optional(), + layout: z.enum(["accounts", "aggregate", "count"]).optional(), + accountNames: z.enum(["number", "label", "none"]).optional(), + order: z.enum([ + "number", + "most-used", + "least-used", + "renewing-earliest", + "renewing-latest", + ]).optional(), + multipliers: z.boolean().optional(), + allotment: z.boolean().optional(), + // The boolean spelling is what an earlier build of this feature took, + // and a config still holding it must not fail validation: this schema + // rejects the whole file as one unit, so one stale value here would + // silently reset every other plugin setting to its default. + resetTimes: z.union([ + z.enum(["never", "low", "always"]), + z.boolean(), + ]).optional(), + resetCredits: z.boolean().optional(), + recovery: z.boolean().optional(), + accounts: z.boolean().optional(), + rows: z.number().int().min(1).max(4).optional(), + showFor: z.enum(["always", "codex-models"]).optional(), + }).optional(), }); export type PluginConfigFromSchema = z.infer; diff --git a/lib/tools/codex-limits.ts b/lib/tools/codex-limits.ts index a4d3c2ab..4ae7e789 100644 --- a/lib/tools/codex-limits.ts +++ b/lib/tools/codex-limits.ts @@ -21,6 +21,7 @@ import { isUsageQuotaRecovered, resolveCodexUsageAccountId, } from "../codex-usage.js"; +import { getQuotaDisplay, loadPluginConfig } from "../config.js"; import { PLUGIN_NAME } from "../constants.js"; import { logWarn } from "../logger.js"; import { @@ -78,6 +79,7 @@ export function createCodexLimitsTool(ctx: ToolContext): ToolDefinition { } = {}) { const ui = resolveUiRuntime(); const maskEmail = resolveMaskEmail(); + const quotaDisplay = getQuotaDisplay(loadPluginConfig()); const outputFormat = normalizeToolOutputFormat(format); const includeSensitiveOutput = includeSensitive === true; const storage = await loadAccounts(); @@ -210,7 +212,7 @@ export function createCodexLimitsTool(ctx: ToolContext): ToolDefinition { accessToken: credentials.accessToken, organizationId: effectiveAccount.organizationId, }); - const usage = parseCodexUsagePayload(payload); + const usage = parseCodexUsagePayload(payload, quotaDisplay); const quotaExhaustedResetAtMs = getUsageQuotaExhaustedResetAtMs( [usage.primary, usage.secondary], ); @@ -268,17 +270,17 @@ export function createCodexLimitsTool(ctx: ToolContext): ToolDefinition { for (const window of [usage.primary, usage.secondary]) { if (!hasUsageWindow(window)) continue; lines.push( - ` ${formatUiKeyValue(ui, formatUsageLimitTitle(window.windowMinutes), formatUsageLimitSummary(window), "muted")}`, + ` ${formatUiKeyValue(ui, formatUsageLimitTitle(window.windowMinutes), formatUsageLimitSummary(window, quotaDisplay), "muted")}`, ); } if (hasUsageWindow(usage.codeReview)) { lines.push( - ` ${formatUiKeyValue(ui, "Code review", formatUsageLimitSummary(usage.codeReview), "muted")}`, + ` ${formatUiKeyValue(ui, "Code review", formatUsageLimitSummary(usage.codeReview, quotaDisplay), "muted")}`, ); } for (const limit of usage.additionalLimits) { lines.push( - ` ${formatUiKeyValue(ui, limit.name, formatUsageLimitSummary(limit.window), "muted")}`, + ` ${formatUiKeyValue(ui, limit.name, formatUsageLimitSummary(limit.window, quotaDisplay), "muted")}`, ); } const planLabel = formatPlanType(usage.planType); @@ -302,17 +304,17 @@ export function createCodexLimitsTool(ctx: ToolContext): ToolDefinition { for (const window of [usage.primary, usage.secondary]) { if (!hasUsageWindow(window)) continue; lines.push( - ` ${formatUsageLimitTitle(window.windowMinutes)}: ${formatUsageLimitSummary(window)}`, + ` ${formatUsageLimitTitle(window.windowMinutes)}: ${formatUsageLimitSummary(window, quotaDisplay)}`, ); } if (hasUsageWindow(usage.codeReview)) { lines.push( - ` Code review: ${formatUsageLimitSummary(usage.codeReview)}`, + ` Code review: ${formatUsageLimitSummary(usage.codeReview, quotaDisplay)}`, ); } for (const limit of usage.additionalLimits) { lines.push( - ` ${limit.name}: ${formatUsageLimitSummary(limit.window)}`, + ` ${limit.name}: ${formatUsageLimitSummary(limit.window, quotaDisplay)}`, ); } const planLabel = formatPlanType(usage.planType); diff --git a/lib/tools/codex-reset.ts b/lib/tools/codex-reset.ts index ff6c061e..ca900540 100644 --- a/lib/tools/codex-reset.ts +++ b/lib/tools/codex-reset.ts @@ -34,6 +34,8 @@ import { resolveCodexUsageAccountId, type CodexUsageSummary, } from "../codex-usage.js"; +import { getQuotaDisplay, loadPluginConfig } from "../config.js"; +import type { QuotaDisplayMode } from "../quota-display.js"; import { loadAccounts, withAccountStorageTransaction } from "../storage.js"; import { clearUnchangedRecoveryState } from "../accounts/stale-state.js"; import { findAccountIndexByIdentity } from "./refresh-account.js"; @@ -87,12 +89,15 @@ function resolveResetAccountIndex( return account - 1; } -function buildUsageLines(usage: CodexUsageSummary): string[] { +function buildUsageLines( + usage: CodexUsageSummary, + mode: QuotaDisplayMode, +): string[] { const lines: string[] = []; for (const window of [usage.primary, usage.secondary]) { if (!hasUsageWindow(window)) continue; lines.push( - ` ${formatUsageLimitTitle(window.windowMinutes)}: ${formatUsageLimitSummary(window)}`, + ` ${formatUsageLimitTitle(window.windowMinutes)}: ${formatUsageLimitSummary(window, mode)}`, ); } return lines; @@ -176,6 +181,7 @@ export function createCodexResetTool(ctx: ToolContext): ToolDefinition { }: CodexResetArgs = {}) { const ui = resolveUiRuntime(); const maskEmail = resolveMaskEmail(); + const quotaDisplay = getQuotaDisplay(loadPluginConfig()); const outputFormat = normalizeToolOutputFormat(format); const resetAction = normalizeResetAction(action); const includeSensitiveOutput = includeSensitive === true; @@ -251,7 +257,7 @@ export function createCodexResetTool(ctx: ToolContext): ToolDefinition { fetchCodexUsage(request), ]); const summary = parseCodexResetCredits(creditsPayload); - const usage = parseCodexUsagePayload(usagePayload); + const usage = parseCodexUsagePayload(usagePayload, quotaDisplay); if (outputFormat === "json") { return renderJsonOutput({ @@ -269,7 +275,7 @@ export function createCodexResetTool(ctx: ToolContext): ToolDefinition { lines.push(`${displayLabel}:`); lines.push(...buildCreditLines(summary)); lines.push("", "current usage:"); - lines.push(...buildUsageLines(usage)); + lines.push(...buildUsageLines(usage, quotaDisplay)); if (summary.availableCount > 0) { lines.push( "", @@ -405,7 +411,10 @@ export function createCodexResetTool(ctx: ToolContext): ToolDefinition { let usageAfter: CodexUsageSummary | undefined; let usageError: string | undefined; try { - usageAfter = parseCodexUsagePayload(await fetchCodexUsage(request)); + usageAfter = parseCodexUsagePayload( + await fetchCodexUsage(request), + quotaDisplay, + ); } catch (error) { usageError = error instanceof Error ? error.message : String(error); } @@ -436,7 +445,7 @@ export function createCodexResetTool(ctx: ToolContext): ToolDefinition { ...(blocksClearError ? [` Note: ${blocksClearError}; the credit was redeemed.`] : []), "", ...(usageAfter - ? ["new usage:", ...buildUsageLines(usageAfter)] + ? ["new usage:", ...buildUsageLines(usageAfter, quotaDisplay)] : [ `new usage: unavailable (${usageError?.slice(0, 160)})`, "The credit was redeemed. Run codex-reset to re-read usage.", diff --git a/lib/tui-quota-cache.ts b/lib/tui-quota-cache.ts index 4b9ca54f..14fd0792 100644 --- a/lib/tui-quota-cache.ts +++ b/lib/tui-quota-cache.ts @@ -14,6 +14,8 @@ import type { CompactQuotaLimit } from "./tui-status.js"; export const TUI_QUOTA_CACHE_VERSION = 1; export const TUI_QUOTA_CACHE_FILE = "oc-codex-multi-auth-tui-quota.json"; +export const TUI_QUOTA_OVERVIEW_CACHE_FILE = + "oc-codex-multi-auth-tui-quota-overview.json"; const TUI_QUOTA_CACHE_WRITE_SKIP_MS = 500; // A snapshot older than one TUI refresh interval is due for a live re-fetch: // the shared cache is only pushed while requests flow, so after an idle gap it @@ -61,9 +63,17 @@ function getDefaultOpenCodeStateDir(): string { return join(homedir(), ".local", "state", "opencode"); } -export function getTuiQuotaCachePath(stateDir?: string): string { +function resolveStateDir(stateDir?: string): string { const envStateDir = process.env.OPENCODE_STATE_DIR?.trim(); - return join(stateDir?.trim() || envStateDir || getDefaultOpenCodeStateDir(), TUI_QUOTA_CACHE_FILE); + return stateDir?.trim() || envStateDir || getDefaultOpenCodeStateDir(); +} + +export function getTuiQuotaCachePath(stateDir?: string): string { + return join(resolveStateDir(stateDir), TUI_QUOTA_CACHE_FILE); +} + +export function getTuiQuotaOverviewCachePath(stateDir?: string): string { + return join(resolveStateDir(stateDir), TUI_QUOTA_OVERVIEW_CACHE_FILE); } function parseFiniteIntHeader( @@ -285,6 +295,23 @@ export async function readTuiQuotaSnapshot( } } +function createTemporaryPath(target: string, now: number): string { + return `${target}.${process.pid}.${now}.${Math.random().toString(36).slice(2)}.tmp`; +} + +async function writeSnapshotFile( + target: string, + temporary: string, + snapshot: unknown, +): Promise { + await fs.mkdir(dirname(target), { recursive: true }); + await fs.writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, { + encoding: "utf-8", + mode: 0o600, + }); + await renameWithWindowsRetry(temporary, target); +} + export async function writeTuiQuotaSnapshot( snapshot: TuiQuotaSnapshot, cachePath?: string, @@ -301,16 +328,8 @@ export async function writeTuiQuotaSnapshot( return; } - const temporary = - `${target}.${process.pid}.${now}.${Math.random().toString(36).slice(2)}.tmp`; - const writePromise = (async () => { - await fs.mkdir(dirname(target), { recursive: true }); - await fs.writeFile(temporary, `${JSON.stringify(snapshot, null, 2)}\n`, { - encoding: "utf-8", - mode: 0o600, - }); - await renameWithWindowsRetry(temporary, target); - })(); + const temporary = createTemporaryPath(target, now); + const writePromise = writeSnapshotFile(target, temporary, snapshot); recentTuiQuotaWrites.set(target, { key: writeKey, at: now, @@ -341,3 +360,108 @@ export async function clearTuiQuotaSnapshot(cachePath?: string): Promise { throw error; } } + +export type TuiQuotaOverviewAccount = { + fingerprint: string; + /** 1-based, matching how `codex-list` and `codex-switch` number accounts. */ + index: number; + /** ChatGPT email, for the surfaces that name an account rather than number it. */ + email?: string; + /** `codex-label` label when one is set, else whatever identity storage has. */ + label?: string; + planType?: string; + /** Banked rate-limit resets redeemable now. */ + resetCredits?: number; + limits: TuiQuotaLimit[]; +}; + +/** + * The pool-wide quota snapshot, held apart from the single-account one. + * + * The two are written on different schedules by different code paths - the + * request path pushes one account per response, while the pool is polled - and + * folding them into one file would make every request rewrite a document + * describing accounts that request never touched. A separate file also leaves + * the existing snapshot's shape, validator and tests untouched, so a build + * that has never heard of the overview reads its own cache unchanged. + */ +export type TuiQuotaOverviewSnapshot = { + version: typeof TUI_QUOTA_CACHE_VERSION; + fetchedAt: number; + accounts: TuiQuotaOverviewAccount[]; +}; + +function isTuiQuotaOverviewAccount( + value: unknown, +): value is TuiQuotaOverviewAccount { + return ( + isRecord(value) && + typeof value.fingerprint === "string" && + value.fingerprint.trim().length > 0 && + typeof value.index === "number" && + Number.isFinite(value.index) && + (value.email === undefined || typeof value.email === "string") && + (value.label === undefined || typeof value.label === "string") && + (value.planType === undefined || typeof value.planType === "string") && + isOptionalFiniteNumber(value.resetCredits) && + Array.isArray(value.limits) && + value.limits.every(isTuiQuotaLimit) + ); +} + +export function isTuiQuotaOverviewSnapshot( + value: unknown, +): value is TuiQuotaOverviewSnapshot { + return ( + isRecord(value) && + value.version === TUI_QUOTA_CACHE_VERSION && + typeof value.fetchedAt === "number" && + Number.isFinite(value.fetchedAt) && + Array.isArray(value.accounts) && + value.accounts.every(isTuiQuotaOverviewAccount) + ); +} + +/** Drop disabled windows, for the reasons in {@link sanitizeTuiQuotaSnapshot}. */ +export function sanitizeTuiQuotaOverviewSnapshot( + snapshot: TuiQuotaOverviewSnapshot, +): TuiQuotaOverviewSnapshot { + return { + ...snapshot, + accounts: snapshot.accounts.map((account) => ({ + ...account, + limits: account.limits.filter((limit) => !isDisabledQuotaLimit(limit)), + })), + }; +} + +export async function readTuiQuotaOverviewSnapshot( + cachePath?: string, +): Promise { + try { + const raw = await fs.readFile( + cachePath ?? getTuiQuotaOverviewCachePath(), + "utf-8", + ); + const parsed = JSON.parse(raw) as unknown; + return isTuiQuotaOverviewSnapshot(parsed) + ? sanitizeTuiQuotaOverviewSnapshot(parsed) + : undefined; + } catch { + return undefined; + } +} + +export async function writeTuiQuotaOverviewSnapshot( + snapshot: TuiQuotaOverviewSnapshot, + cachePath?: string, +): Promise { + const target = cachePath ?? getTuiQuotaOverviewCachePath(); + const temporary = createTemporaryPath(target, Date.now()); + try { + await writeSnapshotFile(target, temporary, snapshot); + } catch (error) { + await fs.unlink(temporary).catch(() => undefined); + throw error; + } +} diff --git a/lib/tui-quota-overview.ts b/lib/tui-quota-overview.ts new file mode 100644 index 00000000..4201d86f --- /dev/null +++ b/lib/tui-quota-overview.ts @@ -0,0 +1,262 @@ +/** + * Gather every account's quota for the pool-wide status line. + * + * The single-account status the request path maintains cannot answer "where + * does the pool stand" - it only ever describes the account that served the + * last response. This reads `/wham/usage` for each distinct account instead, + * on a bounded interval, and caches the result where every OpenCode window on + * the machine can share it. + * + * The cache is what keeps this cheap. A reader takes a snapshot that is still + * fresh rather than issuing its own requests, so N terminals open on the same + * pool cost one round of requests between them rather than N, and a terminal + * that has just started renders immediately instead of showing nothing while + * seven accounts are queried one after another. + */ + +import { + createUsageAccountFingerprint, + deduplicateUsageAccountIndices, + ensureCodexUsageAccessToken, + fetchCodexUsage, + getUsageLeftPercent, + hasUsageWindow, + parseCodexUsagePayload, + resolveCodexUsageAccountId, + type CodexUsageSummary, + type LimitWindow, +} from "./codex-usage.js"; +import { logDebug } from "./logger.js"; +import type { QuotaOverviewAccount } from "./quota-overview.js"; +import { loadAccounts, type AccountStorageV3 } from "./storage.js"; +import { + isFreshTuiQuotaSnapshot, + readTuiQuotaOverviewSnapshot, + writeTuiQuotaOverviewSnapshot, + TUI_QUOTA_CACHE_VERSION, + type TuiQuotaOverviewAccount, + type TuiQuotaOverviewSnapshot, + type TuiQuotaSnapshot, +} from "./tui-quota-cache.js"; + +/** + * Accounts queried at once. Matches the quota monitor's own limit: this talks + * to the same endpoint with the same credentials, and a pool of a dozen seats + * should not arrive as a dozen simultaneous requests. + */ +const MAX_CONCURRENCY = 2; + +function toOverviewLimit(window: LimitWindow, label: string) { + return { + label, + leftPercent: getUsageLeftPercent(window.usedPercent) ?? null, + usedPercent: window.usedPercent, + windowMinutes: window.windowMinutes, + resetAtMs: window.resetAtMs, + }; +} + +/** + * Reduce one account's usage document to what the status line needs. + * + * Only the primary and secondary windows are kept. Code-review and the other + * additional quotas do not govern ordinary model requests, so counting them + * would let a spent code-review allowance report an account as unusable for + * work it can still do. + */ +export function toOverviewAccount(params: { + fingerprint: string; + index: number; + usage: CodexUsageSummary; + email?: string; + label?: string; +}): TuiQuotaOverviewAccount { + const limits = [ + { window: params.usage.primary, label: "5h" }, + { window: params.usage.secondary, label: "weekly" }, + ] + .filter(({ window }) => hasUsageWindow(window)) + .map(({ window, label }) => toOverviewLimit(window, label)); + const resetCredits = params.usage.resetCredits; + return { + fingerprint: params.fingerprint, + index: params.index, + email: params.email?.trim() || undefined, + // Only a label the user set, never the accountId/organizationId + // fallbacks other surfaces use: those identify an account without + // naming it, and a 36-character UUID on a status line names nothing. + label: params.label?.trim() || undefined, + planType: params.usage.planType ?? undefined, + // `applicableNow` is the count that can be redeemed against a window + // that is actually spent, which is the only one worth showing beside a + // percentage. It falls back to the banked total only when the server + // stated a count this build could not read. + resetCredits: + resetCredits?.applicableNow ?? resetCredits?.available ?? undefined, + limits, + }; +} + +async function fetchOverviewAccount( + storage: AccountStorageV3, + index: number, +): Promise { + const account = storage.accounts[index]; + if (!account) return undefined; + try { + const credentials = await ensureCodexUsageAccessToken({ storage, account }); + const accountId = resolveCodexUsageAccountId({ + account, + accessToken: credentials.accessToken, + }); + if (!accountId) return undefined; + const usage = parseCodexUsagePayload( + await fetchCodexUsage({ + accountId, + accessToken: credentials.accessToken, + organizationId: account.organizationId, + normalizeAccountErrors: true, + }), + ); + return toOverviewAccount({ + fingerprint: createUsageAccountFingerprint(account), + index: index + 1, + usage, + email: account.email, + label: account.accountLabel, + }); + } catch (error) { + logDebug( + `Failed to fetch pool quota for one account: ${(error as Error).message}`, + ); + return undefined; + } +} + +export async function fetchTuiQuotaOverview(params: { + cachePath?: string; + now?: number; + loadStorage?: () => Promise; +}): Promise { + const now = params.now ?? Date.now(); + const cached = await readTuiQuotaOverviewSnapshot(params.cachePath); + if (cached && isFreshTuiQuotaSnapshot(cached, now)) return cached; + + const storage = await (params.loadStorage ?? loadAccounts)(); + if (!storage || storage.accounts.length === 0) return undefined; + + const indices = deduplicateUsageAccountIndices(storage); + const accounts: TuiQuotaOverviewAccount[] = []; + // Set when an account this pass could not fetch keeps its previous + // reading instead of dropping out of the snapshot. + let carriedOver = false; + for (let offset = 0; offset < indices.length; offset += MAX_CONCURRENCY) { + const chunk = indices.slice(offset, offset + MAX_CONCURRENCY); + const results = await Promise.all( + chunk.map((index) => fetchOverviewAccount(storage, index)), + ); + for (const [position, result] of results.entries()) { + if (result) { + accounts.push(result); + continue; + } + // A failed fetch must not drop the account out of the snapshot: the + // pool would be judged on a subset, and one transient error on the + // only account with headroom would flip the line to "fully spent" + // and surface the resets screen wrongly. The cached reading is reused + // only for the SAME account - same pool position and same credential + // fingerprint, so a pool that changed membership never inherits a + // stranger's numbers - and the snapshot below keeps the older fetch + // time so the reading is rendered stale rather than current. + const index = chunk[position]; + const account = + index === undefined ? undefined : storage.accounts[index]; + const previous = + index === undefined || account === undefined + ? undefined + : cached?.accounts.find( + (candidate) => + candidate.index === index + 1 && + candidate.fingerprint === + createUsageAccountFingerprint(account), + ); + if (previous) { + accounts.push(previous); + carriedOver = true; + } + } + } + // Every account failing means the pool was not observed at all, which is a + // different statement from "the pool is empty". Keeping the previous + // snapshot lets the line stay on the last thing known to be true rather + // than blanking on one bad network moment. + if (accounts.length === 0) return cached; + + accounts.sort((left, right) => left.index - right.index); + const snapshot: TuiQuotaOverviewSnapshot = { + version: TUI_QUOTA_CACHE_VERSION, + // A snapshot carrying a reused reading is only as fresh as that + // reading, so it keeps the previous fetch time - the stale flag is how + // the line says "part of this is not new" without dropping it. + fetchedAt: + carriedOver && cached ? Math.min(cached.fetchedAt, now) : now, + accounts, + }; + try { + await writeTuiQuotaOverviewSnapshot(snapshot, params.cachePath); + } catch (error) { + logDebug( + `Failed to cache the pool quota snapshot: ${(error as Error).message}`, + ); + } + return snapshot; +} + +/** + * Fold the request path's live reading of one account into the polled pool. + * + * The pool is re-read on an interval, but the account currently serving + * requests has its quota pushed from response headers after every single + * response. Without this the line would show that account's numbers as they + * were up to five minutes ago while the user watches their own requests spend + * it - the one account whose figure a reader can check against their own + * activity would be the one that looks wrong. + * + * Only a snapshot NEWER than the poll is merged, so a header reading left over + * from before the poll cannot overwrite it with older numbers. + */ +export function mergeOverviewWithLatestAccount( + snapshot: TuiQuotaOverviewSnapshot, + latest: TuiQuotaSnapshot | undefined, +): TuiQuotaOverviewSnapshot { + if (!latest || latest.fetchedAt <= snapshot.fetchedAt) return snapshot; + if (latest.limits.length === 0) return snapshot; + let merged = false; + const accounts = snapshot.accounts.map((account) => { + if (account.fingerprint !== latest.fingerprint) return account; + merged = true; + return { + ...account, + planType: latest.planType ?? account.planType, + email: account.email ?? (latest.accountEmail?.trim() || undefined), + limits: latest.limits, + }; + }); + return merged ? { ...snapshot, accounts } : snapshot; +} + +export function toQuotaOverviewAccounts( + snapshot: TuiQuotaOverviewSnapshot, +): QuotaOverviewAccount[] { + return snapshot.accounts.map((account) => ({ + index: account.index, + email: account.email, + label: account.label, + planType: account.planType, + resetCredits: account.resetCredits, + windows: account.limits.map((limit) => ({ + leftPercent: limit.leftPercent ?? undefined, + resetAtMs: limit.resetAtMs, + })), + })); +} diff --git a/lib/tui-status.ts b/lib/tui-status.ts index d30d177e..8f00037b 100644 --- a/lib/tui-status.ts +++ b/lib/tui-status.ts @@ -2,6 +2,19 @@ import type { Config } from "@opencode-ai/sdk/v2"; import { maskEmailForDisplay } from "./account-display.js"; import { getEffortSuffix } from "./request/helpers/effort-suffix.js"; import { formatPlanType } from "./auth/plan-tier.js"; +import { + formatQuotaOverviewCandidates, + formatQuotaResetsCandidates, + resolveQuotaOverviewTonePercent, + type QuotaOverviewAccount, + type QuotaOverviewOptions, +} from "./quota-overview.js"; +import { + DEFAULT_QUOTA_DISPLAY_MODE, + formatNamedQuotaPercent, + formatQuotaPercent, + type QuotaDisplayMode, +} from "./quota-display.js"; export type ReasoningVariant = | "none" @@ -279,10 +292,11 @@ function formatQuotaLimit( limit: CompactQuotaLimit, resetLimit: CompactQuotaLimit | undefined, includeReset: boolean, + mode: QuotaDisplayMode, ): string | undefined { if (!isPercent(limit.leftPercent)) return undefined; const label = limit.label.trim() || "quota"; - const base = `${label} ${limit.leftPercent}%`; + const base = `${label} ${formatQuotaPercent(limit.leftPercent, mode)}`; const reset = includeReset && limit === resetLimit ? formatResetTime(limit.resetAtMs) : undefined; return reset ? `${base} resets ${reset}` : base; @@ -347,19 +361,23 @@ function findResetLimitForStatus( function formatQuotaParts( quota: CompactQuotaStatus, includeReset: boolean, + mode: QuotaDisplayMode, ): string[] { if (quota.type !== "ready") return []; const resetLimit = includeReset ? findResetLimitForStatus(quota.limits) : undefined; return quota.limits - .map((limit) => formatQuotaLimit(limit, resetLimit, includeReset)) + .map((limit) => formatQuotaLimit(limit, resetLimit, includeReset, mode)) .filter((part): part is string => Boolean(part)); } -function formatQuota(quota: CompactQuotaStatus): string | undefined { +function formatQuota( + quota: CompactQuotaStatus, + mode: QuotaDisplayMode, +): string | undefined { if (quota.type === "ready") { - const parts = formatQuotaParts(quota, true); + const parts = formatQuotaParts(quota, true, mode); return parts.length > 0 ? parts.join(STATUS_SEPARATOR) : undefined; } if (quota.type === "missing") return "no auth"; @@ -400,14 +418,16 @@ export function formatPromptStatusText(params: { quota: CompactQuotaStatus; width?: number; maskEmail?: boolean; + quotaDisplay?: QuotaDisplayMode; }): string { const variant = params.variant; + const mode = params.quotaDisplay ?? DEFAULT_QUOTA_DISPLAY_MODE; const accountForms = formatAccountHints(params.quota, params.maskEmail); - const quotaParts = formatQuotaParts(params.quota, true); - const quotaPartsWithoutReset = formatQuotaParts(params.quota, false); + const quotaParts = formatQuotaParts(params.quota, true, mode); + const quotaPartsWithoutReset = formatQuotaParts(params.quota, false, mode); const quota = quotaParts.length > 0 ? quotaParts.join(STATUS_SEPARATOR) - : formatQuota(params.quota); + : formatQuota(params.quota, mode); const primaryQuota = quotaParts[0] ?? quota; const quotaWithoutReset = quotaPartsWithoutReset.length > 0 ? quotaPartsWithoutReset.join(STATUS_SEPARATOR) @@ -437,6 +457,168 @@ export function formatPromptStatusText(params: { return candidates.find((candidate) => candidate.length <= maxChars) ?? ""; } +/** + * Columns this line may not spend, because something else on the row owns + * them: the prompt border and padding, and the model label sitting to the + * left of this slot ("Build - Big Pickle OpenCode Zen" is 31 characters). + */ +const OVERVIEW_STATUS_RESERVED_CHARS = 40; + +/** + * Character budget for the pool-wide line. + * + * Two bounds, whichever is tighter. The share cap keeps a wide terminal from + * handing the whole row to this line; the reserve keeps a narrow one from + * overrunning the model label beside it. The reserve is what binds at ordinary + * widths, and it has to: unlike the single-account line above - which is short + * enough that its budget is never the thing that stops it - this line grows + * with the size of the pool and reaches its budget on every render. + * + * Overflow is NOT absorbed by the renderer's truncation. At 80 columns a + * 48-character line was ellipsized through its middle, destroying account + * numbers and reset times either side of the cut, AND pushed the model label + * into a second row. The reserve is sized so that does not happen: 40 at 80 + * columns, which is what measurably fits beside a 31-character label. + */ +function maxOverviewStatusChars(width: number | undefined): number { + if (!width || !Number.isFinite(width)) return 32; + return Math.max( + Math.min(12, width), + Math.min( + Math.floor(width * 0.6), + width - OVERVIEW_STATUS_RESERVED_CHARS, + ), + ); +} + +/** + * Columns available to this line, preferring what the renderer measured. + * + * `width` is the whole terminal, which is the wrong number whenever anything + * else is on the row - a sidebar, the model label - and it is wrong by however + * much those take. A measured value comes from the laid-out node itself and + * needs no reserve at all, so it is used verbatim. + */ +function resolveOverviewChars( + width: number | undefined, + availableChars: number | undefined, +): number { + if ( + typeof availableChars === "number" && + Number.isFinite(availableChars) && + availableChars > 0 + ) { + return Math.floor(availableChars); + } + return maxOverviewStatusChars(width); +} + +/** + * Break one rendering across rows, at the separators it already has. + * + * Only `, ` boundaries are used, so a row never ends mid-account: a line cut + * between `#2` and its percentage is worse than no second row at all. A + * candidate with any single segment wider than the row cannot be laid out this + * way and is rejected, which sends the caller to the next rung down. + */ +export function wrapStatusCandidate( + candidate: string, + maxChars: number, + maxRows: number, +): string[] | undefined { + if (candidate.length <= maxChars) return [candidate]; + if (maxRows <= 1 || maxChars <= 0) return undefined; + const segments = candidate.split(", "); + const rows: string[] = []; + let row = ""; + for (const [position, segment] of segments.entries()) { + const piece = position === segments.length - 1 ? segment : `${segment},`; + if (piece.length > maxChars) return undefined; + if (row.length === 0) { + row = piece; + continue; + } + const joined = `${row} ${piece}`; + if (joined.length <= maxChars) { + row = joined; + continue; + } + rows.push(row); + if (rows.length >= maxRows) return undefined; + row = piece; + } + if (row.length > 0) rows.push(row); + return rows.length > 0 && rows.length <= maxRows ? rows : undefined; +} + +/** + * Lay a candidate ladder out in the space available, in up to `maxRows` rows. + * + * The ladder is walked once, and the first rung that fits wins - whether it + * fits on one row or has to be broken across two. Trying every rung on one row + * before allowing a second would shed detail the reader has room for. + */ +export function fitStatusLines( + candidates: readonly string[], + maxChars: number, + maxRows: number, +): string[] { + for (const candidate of candidates) { + const rows = wrapStatusCandidate(candidate, maxChars, maxRows); + if (rows) return rows; + } + const last = candidates.at(-1); + return last ? [last] : []; +} + +/** + * Render the whole account pool, degrading through + * {@link formatQuotaOverviewCandidates} until one form fits. + */ +export function formatQuotaOverviewStatusLines(params: { + accounts: readonly QuotaOverviewAccount[]; + options: QuotaOverviewOptions; + width?: number; + availableChars?: number; + maxRows?: number; +}): string[] { + return fitStatusLines( + formatQuotaOverviewCandidates(params.accounts, params.options), + resolveOverviewChars(params.width, params.availableChars), + params.maxRows ?? 1, + ); +} + +export function formatQuotaOverviewStatusText(params: { + accounts: readonly QuotaOverviewAccount[]; + options: QuotaOverviewOptions; + width?: number; + availableChars?: number; +}): string { + return formatQuotaOverviewStatusLines(params)[0] ?? ""; +} + +/** The banked-reset line, laid out the same way as the pool line. */ +export function formatQuotaResetsStatusLines(params: { + accounts: readonly QuotaOverviewAccount[]; + options: QuotaOverviewOptions; + width?: number; + availableChars?: number; + maxRows?: number; +}): string[] { + const candidates = formatQuotaResetsCandidates(params.accounts, { + maskEmail: params.options.maskEmail, + names: params.options.names, + now: params.options.now, + }); + if (candidates.length === 0) return []; + return fitStatusLines( + candidates, + resolveOverviewChars(params.width, params.availableChars), + params.maxRows ?? 1, + ); +} + export type QuotaPromptTone = | "normal" | "warning" @@ -444,6 +626,26 @@ export type QuotaPromptTone = | "stale" | "unknown"; +/** + * Colour the pool by its healthiest account. + * + * A pool is only in trouble when nothing in it has room left, so the account + * with the most headroom decides the colour: keying on the worst account would + * paint the line red for a spent seat that rotation has already stopped + * selecting while six healthy ones serve every request. + */ +export function resolveQuotaOverviewTone( + accounts: readonly QuotaOverviewAccount[], + stale = false, +): QuotaPromptTone { + if (stale) return "stale"; + const best = resolveQuotaOverviewTonePercent(accounts); + if (best === undefined) return "unknown"; + if (best <= DANGER_LIMIT_LEFT_PERCENT) return "danger"; + if (best <= WARNING_LIMIT_LEFT_PERCENT) return "warning"; + return "normal"; +} + export function resolveQuotaPromptTone( quota: CompactQuotaStatus, ): QuotaPromptTone { @@ -568,19 +770,22 @@ function formatUpdatedAge(fetchedAt: number | undefined, now: number): string { return `${days}d ago`; } -function formatDetailsLimit(limit: CompactQuotaLimit): string { +function formatDetailsLimit( + limit: CompactQuotaLimit, + mode: QuotaDisplayMode, +): string { const label = limit.label.trim() || "quota"; - const left = isPercent(limit.leftPercent) - ? `${limit.leftPercent}% left` + const percent = isPercent(limit.leftPercent) + ? formatNamedQuotaPercent(limit.leftPercent, mode) : "unavailable"; const reset = formatReset(limit.resetAtMs); - return reset ? `${label}: ${left}, resets ${reset}` : `${label}: ${left}`; + return reset ? `${label}: ${percent}, resets ${reset}` : `${label}: ${percent}`; } export function formatQuotaDetailsText( quota: CompactQuotaStatus, now = Date.now(), - options: { maskEmail?: boolean } = {}, + options: { maskEmail?: boolean; quotaDisplay?: QuotaDisplayMode } = {}, ): string { if (quota.type === "loading") return "Quota is loading."; if (quota.type === "missing") return "No Codex OAuth account is configured."; @@ -598,7 +803,9 @@ export function formatQuotaDetailsText( lines.push(`Account: ${accountHint}`); } for (const limit of quota.limits) { - lines.push(formatDetailsLimit(limit)); + lines.push( + formatDetailsLimit(limit, options.quotaDisplay ?? DEFAULT_QUOTA_DISPLAY_MODE), + ); } // Named through formatPlanType like the stored copy, so one seat does not // print "Business" in codex-list and "team" here in the same session. diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 282cc4c7..642dbcb2 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -931,14 +931,15 @@ async function loadWarmRuntime(env) { } async function loadLimitsRuntime(env) { - const [storageMod, usageMod, shutdownMod, loggerMod] = await loadDistModules( - ["storage.js", "codex-usage.js", "shutdown.js", "logger.js"], - "limits", - ); + const [storageMod, usageMod, shutdownMod, loggerMod, configMod] = + await loadDistModules( + ["storage.js", "codex-usage.js", "shutdown.js", "logger.js", "config.js"], + "limits", + ); // Fetching usage can refresh (and therefore persist) a token, so the same // process-owns-termination rule as `warm` applies. shutdownMod.setShutdownOwnsProcess(true); - return { storageMod, usageMod, shutdownMod, loggerMod }; + return { storageMod, usageMod, shutdownMod, loggerMod, configMod }; } export async function runWarmCommand(parsed, options = {}) { @@ -1102,7 +1103,8 @@ export async function runLimitsCommand(parsed, options = {}) { return { exitCode: 1, action: "limits", storagePath }; } - const { storageMod, usageMod, loggerMod } = runtime; + const { storageMod, usageMod, loggerMod, configMod } = runtime; + const quotaDisplay = configMod.getQuotaDisplay(configMod.loadPluginConfig()); // Point dist storage at the resolved accounts file so a refreshed token is // persisted to the SAME file the rest of the toolchain reads. storageMod.setStoragePathDirect(storagePath); @@ -1182,6 +1184,7 @@ export async function runLimitsCommand(parsed, options = {}) { accessToken, organizationId: account.organizationId, }), + quotaDisplay, ); const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([ usage.primary, diff --git a/test/index.test.ts b/test/index.test.ts index b9d995b3..8235019b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -176,6 +176,7 @@ vi.mock("../lib/config.js", () => ({ getCodexTuiColorProfile: () => "ansi16", getCodexTuiGlyphMode: () => "ascii", getCodexTuiMaskEmail: vi.fn(() => false), + getQuotaDisplay: vi.fn(() => "free"), getBeginnerSafeMode: () => false, loadPluginConfig: vi.fn((): import("../lib/types.js").PluginConfig => ({})), })); @@ -1776,6 +1777,95 @@ describe("OpenAIOAuthPlugin", () => { ); }); + it("reports consumption instead of headroom when quotaDisplay is used", async () => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getQuotaDisplay).mockReturnValue("used"); + mockStorage.accounts = [ + { + refreshToken: "r1", + accountId: "acc-1", + email: "user@example.com", + accessToken: "access-1", + expiresAt: Date.now() + 3600_000, + }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { + used_percent: 13, + limit_window_seconds: 18000, + }, + secondary_window: { + used_percent: 36, + limit_window_seconds: 604800, + }, + }, + code_review_rate_limit: { + primary_window: { + used_percent: 0, + limit_window_seconds: 604800, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + try { + const result = await plugin.tool["codex-limits"].execute(); + + expect(result).toContain("5h limit: 13% used"); + expect(result).toContain("Weekly limit: 36% used"); + expect(result).toContain("Code review: 0% used"); + expect(result).not.toContain("left"); + } finally { + vi.mocked(configModule.getQuotaDisplay).mockReturnValue("free"); + } + }); + + it("keeps the numeric usage fields identical to the free-mode reading", async () => { + const configModule = await import("../lib/config.js"); + vi.mocked(configModule.getQuotaDisplay).mockReturnValue("used"); + mockStorage.accounts = [ + { + refreshToken: "r1", + accountId: "acc-1", + email: "user@example.com", + accessToken: "access-1", + expiresAt: Date.now() + 3600_000, + }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { + used_percent: 13, + limit_window_seconds: 18000, + }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + try { + const parsed = JSON.parse( + await plugin.tool["codex-limits"].execute({ format: "json" }), + ); + expect(parsed.accounts[0].limits[0]).toMatchObject({ + name: "5h limit", + usedPercent: 13, + leftPercent: 87, + summary: "13% used", + }); + } finally { + vi.mocked(configModule.getQuotaDisplay).mockReturnValue("free"); + } + }); + it("blocks a fully spent usage quota before round-robin can spend Credits", async () => { const weeklyResetAt = Math.floor(Date.now() / 1000) + 86_400; mockStorage.accounts = [ diff --git a/test/plan-allotment.test.ts b/test/plan-allotment.test.ts new file mode 100644 index 00000000..1fbef38c --- /dev/null +++ b/test/plan-allotment.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_PLAN_WEIGHT, + describePlanAllotment, + formatPlanMultiplier, + getPlanWeight, + normalizePlanSlug, +} from "../lib/plan-allotment.js"; + +describe("normalizePlanSlug", () => { + it("collapses the spellings the same tier arrives under", () => { + expect(normalizePlanSlug("team")).toBe("team"); + expect(normalizePlanSlug("chatgptteamplan")).toBe("team"); + expect(normalizePlanSlug("ChatGPT_Team_Plan")).toBe("team"); + expect(normalizePlanSlug("self_serve_business_prolite")).toBe( + "self serve business prolite", + ); + }); + + it("treats blank and non-string input as absent", () => { + expect(normalizePlanSlug("")).toBeUndefined(); + expect(normalizePlanSlug(" ")).toBeUndefined(); + expect(normalizePlanSlug(null)).toBeUndefined(); + expect(normalizePlanSlug(undefined)).toBeUndefined(); + }); +}); + +describe("describePlanAllotment", () => { + it("places the plans this pool actually holds", () => { + expect(describePlanAllotment("pro")).toEqual({ + weight: 20, + multiplier: "20x", + monthlyUsd: 200, + }); + expect(describePlanAllotment("self_serve_business_prolite")).toEqual({ + weight: 5, + multiplier: "5x", + monthlyUsd: 125, + }); + expect(describePlanAllotment("team")).toEqual({ + weight: 1, + multiplier: "1x", + monthlyUsd: 25, + }); + expect(describePlanAllotment("plus")).toEqual({ + weight: 1, + multiplier: "1x", + monthlyUsd: 20, + }); + }); + + it("keeps the $100 Pro apart from the $200 one", () => { + expect(describePlanAllotment("pro 5x").multiplier).toBe("5x"); + expect(describePlanAllotment("pro_legacy").multiplier).toBe("5x"); + expect(describePlanAllotment("pro 20x").multiplier).toBe("20x"); + }); + + it("reads the premium Business seat as a seat, not as personal Pro Lite", () => { + // Both normalize to text containing `prolite`; only the one naming a + // business workspace is the $125 seat. + expect(describePlanAllotment("self_serve_business_prolite").monthlyUsd).toBe(125); + expect(describePlanAllotment("prolite").monthlyUsd).toBe(100); + }); + + it("states no ratio for a bare business workspace", () => { + expect(describePlanAllotment("business")).toEqual({}); + }); + + it("separates Business Standard from Business Premium", () => { + expect(describePlanAllotment("business_standard")).toEqual({ + weight: 1, + multiplier: "1x", + monthlyUsd: 25, + }); + }); + + it("states no ratio for plans that carry no Codex allotment", () => { + expect(describePlanAllotment("free")).toEqual({}); + expect(describePlanAllotment("go")).toEqual({}); + expect(describePlanAllotment("enterprise")).toEqual({}); + expect(describePlanAllotment("something-new")).toEqual({}); + expect(describePlanAllotment(null)).toEqual({}); + }); +}); + +describe("getPlanWeight", () => { + it("falls back to the baseline seat rather than removing the account", () => { + expect(getPlanWeight("enterprise")).toBe(DEFAULT_PLAN_WEIGHT); + expect(getPlanWeight(undefined)).toBe(DEFAULT_PLAN_WEIGHT); + expect(getPlanWeight("pro")).toBe(20); + }); +}); + +describe("formatPlanMultiplier", () => { + it("renders the badge only for a plan that states a ratio", () => { + expect(formatPlanMultiplier("pro")).toBe("20x"); + expect(formatPlanMultiplier("self_serve_business_prolite")).toBe("5x"); + expect(formatPlanMultiplier("enterprise")).toBeUndefined(); + }); +}); diff --git a/test/plugin-config.test.ts b/test/plugin-config.test.ts index fe660950..467af36e 100644 --- a/test/plugin-config.test.ts +++ b/test/plugin-config.test.ts @@ -122,6 +122,7 @@ describe('Plugin Configuration', () => { codexTuiGlyphMode: 'ascii', maskEmail: false, maskEmailInQuotaDetails: false, + quotaDisplay: 'free', beginnerSafeMode: false, fastSession: false, fastSessionStrategy: 'hybrid', @@ -173,6 +174,7 @@ describe('Plugin Configuration', () => { codexTuiGlyphMode: 'ascii', maskEmail: false, maskEmailInQuotaDetails: false, + quotaDisplay: 'free', beginnerSafeMode: false, fastSession: false, fastSessionStrategy: 'hybrid', @@ -221,6 +223,7 @@ describe('Plugin Configuration', () => { codexTuiGlyphMode: 'ascii', maskEmail: false, maskEmailInQuotaDetails: false, + quotaDisplay: 'free', beginnerSafeMode: false, fastSession: false, fastSessionStrategy: 'hybrid', @@ -280,6 +283,7 @@ describe('Plugin Configuration', () => { codexTuiGlyphMode: 'ascii', maskEmail: false, maskEmailInQuotaDetails: false, + quotaDisplay: 'free', beginnerSafeMode: false, fastSession: false, fastSessionStrategy: 'hybrid', @@ -333,6 +337,7 @@ describe('Plugin Configuration', () => { codexTuiGlyphMode: 'ascii', maskEmail: false, maskEmailInQuotaDetails: false, + quotaDisplay: 'free', beginnerSafeMode: false, fastSession: false, fastSessionStrategy: 'hybrid', diff --git a/test/quota-display.test.ts b/test/quota-display.test.ts new file mode 100644 index 00000000..74f9d4ad --- /dev/null +++ b/test/quota-display.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { getQuotaDisplay } from "../lib/config.js"; +import { + formatUsageLimitSummary, + parseCodexUsagePayload, + type UsagePayload, +} from "../lib/codex-usage.js"; +import { + formatNamedQuotaPercent, + formatQuotaPercent, + toQuotaDisplayPercent, +} from "../lib/quota-display.js"; +import { formatQuotaNotification } from "../lib/quota-notifications.js"; +import { PluginConfigSchema } from "../lib/schemas.js"; +import { + formatPromptStatusText, + formatQuotaDetailsText, + resolveQuotaPromptTone, + type CompactQuotaStatus, +} from "../lib/tui-status.js"; + +const ENV_KEY = "CODEX_AUTH_QUOTA_DISPLAY"; +const FIVE_HOUR_SECONDS = 300 * 60; +const WEEKLY_SECONDS = 10080 * 60; + +function readyQuota( + limits: CompactQuotaStatus extends { limits: infer L } ? L : never, +): CompactQuotaStatus { + return { type: "ready", limits, stale: false }; +} + +function usagePayload( + primaryUsedPercent: number | undefined, + secondaryUsedPercent: number | undefined, +): UsagePayload { + return { + rate_limit: { + primary_window: { + used_percent: primaryUsedPercent, + limit_window_seconds: FIVE_HOUR_SECONDS, + }, + secondary_window: { + used_percent: secondaryUsedPercent, + limit_window_seconds: WEEKLY_SECONDS, + }, + }, + }; +} + +describe("quota display percentages", () => { + it("inverts the percentage only in used mode", () => { + expect(toQuotaDisplayPercent(88, "free")).toBe(88); + expect(toQuotaDisplayPercent(88, "used")).toBe(12); + expect(formatQuotaPercent(88, "free")).toBe("88%"); + expect(formatQuotaPercent(88, "used")).toBe("12%"); + expect(formatNamedQuotaPercent(88, "free")).toBe("88% left"); + expect(formatNamedQuotaPercent(88, "used")).toBe("12% used"); + }); + + it("renders an untouched window as fully free and zero used", () => { + expect(formatQuotaPercent(100, "free")).toBe("100%"); + expect(formatQuotaPercent(100, "used")).toBe("0%"); + expect(formatNamedQuotaPercent(100, "free")).toBe("100% left"); + expect(formatNamedQuotaPercent(100, "used")).toBe("0% used"); + }); + + it("renders a spent window as zero free and fully used", () => { + expect(formatQuotaPercent(0, "free")).toBe("0%"); + expect(formatQuotaPercent(0, "used")).toBe("100%"); + expect(formatNamedQuotaPercent(0, "free")).toBe("0% left"); + expect(formatNamedQuotaPercent(0, "used")).toBe("100% used"); + }); + + it("keeps both readings of one window adding up to 100", () => { + for (const leftPercent of [0, 1, 12, 33, 50, 87, 99, 100]) { + expect( + toQuotaDisplayPercent(leftPercent, "free") + + toQuotaDisplayPercent(leftPercent, "used"), + ).toBe(100); + } + }); +}); + +describe("quotaDisplay setting", () => { + let previous: string | undefined; + + beforeEach(() => { + previous = process.env[ENV_KEY]; + delete process.env[ENV_KEY]; + }); + + afterEach(() => { + if (previous === undefined) delete process.env[ENV_KEY]; + else process.env[ENV_KEY] = previous; + }); + + it("defaults to free, matching how Codex reports a quota", () => { + expect(getQuotaDisplay({})).toBe("free"); + }); + + it("honours the configured mode", () => { + expect(getQuotaDisplay({ quotaDisplay: "used" })).toBe("used"); + expect(getQuotaDisplay({ quotaDisplay: "free" })).toBe("free"); + }); + + it("lets the environment override the configured mode in both directions", () => { + process.env[ENV_KEY] = "used"; + expect(getQuotaDisplay({ quotaDisplay: "free" })).toBe("used"); + process.env[ENV_KEY] = "free"; + expect(getQuotaDisplay({ quotaDisplay: "used" })).toBe("free"); + }); + + it("falls back to the configured mode when the environment value is unknown", () => { + process.env[ENV_KEY] = "percent"; + expect(getQuotaDisplay({ quotaDisplay: "used" })).toBe("used"); + }); + + it("accepts only the two modes in the plugin config schema", () => { + expect(PluginConfigSchema.safeParse({ quotaDisplay: "free" }).success).toBe( + true, + ); + expect(PluginConfigSchema.safeParse({ quotaDisplay: "used" }).success).toBe( + true, + ); + expect( + PluginConfigSchema.safeParse({ quotaDisplay: "remaining" }).success, + ).toBe(false); + }); +}); + +describe("usage limit summaries follow the display mode", () => { + it("names which percentage it is reporting", () => { + const window = { usedPercent: 12, windowMinutes: 300 }; + expect(formatUsageLimitSummary(window, "free")).toBe("88% left"); + expect(formatUsageLimitSummary(window, "used")).toBe("12% used"); + }); + + it("defaults to free when no mode is supplied", () => { + expect(formatUsageLimitSummary({ usedPercent: 12 })).toBe("88% left"); + }); + + it("reports an untouched and a spent window at both boundaries", () => { + expect(formatUsageLimitSummary({ usedPercent: 0 }, "free")).toBe("100% left"); + expect(formatUsageLimitSummary({ usedPercent: 0 }, "used")).toBe("0% used"); + expect(formatUsageLimitSummary({ usedPercent: 100 }, "free")).toBe("0% left"); + expect(formatUsageLimitSummary({ usedPercent: 100 }, "used")).toBe( + "100% used", + ); + }); + + it("reports an unreadable percentage as unavailable in either mode", () => { + expect(formatUsageLimitSummary({ windowMinutes: 300 }, "free")).toBe( + "unavailable", + ); + expect(formatUsageLimitSummary({ windowMinutes: 300 }, "used")).toBe( + "unavailable", + ); + }); + + it("words every rendered limit in the parsed payload", () => { + const free = parseCodexUsagePayload(usagePayload(12, 0), "free"); + const used = parseCodexUsagePayload(usagePayload(12, 0), "used"); + expect(free.limits.map((limit) => limit.summary)).toEqual([ + "88% left", + "100% left", + ]); + expect(used.limits.map((limit) => limit.summary)).toEqual([ + "12% used", + "0% used", + ]); + }); + + it("leaves the machine-readable percentages identical in both modes", () => { + const free = parseCodexUsagePayload(usagePayload(12, 100), "free"); + const used = parseCodexUsagePayload(usagePayload(12, 100), "used"); + const numbers = (summary: typeof free) => + summary.limits.map((limit) => ({ + name: limit.name, + usedPercent: limit.usedPercent, + leftPercent: limit.leftPercent, + windowMinutes: limit.windowMinutes, + })); + expect(numbers(used)).toEqual(numbers(free)); + expect(numbers(free)).toEqual([ + { + name: "5h limit", + usedPercent: 12, + leftPercent: 88, + windowMinutes: 300, + }, + { + name: "Weekly limit", + usedPercent: 100, + leftPercent: 0, + windowMinutes: 10080, + }, + ]); + }); +}); + +describe("TUI quota surfaces follow the display mode", () => { + const sep = ` ${String.fromCharCode(183)} `; + const quota = readyQuota([ + { label: "5h", leftPercent: 88 }, + { label: "7d", leftPercent: 83 }, + ]); + + it("prints the bare percentage the configured mode asks for", () => { + expect(formatPromptStatusText({ quota, width: 120 })).toBe( + `5h 88%${sep}7d 83%`, + ); + expect( + formatPromptStatusText({ quota, width: 120, quotaDisplay: "free" }), + ).toBe(`5h 88%${sep}7d 83%`); + expect( + formatPromptStatusText({ quota, width: 120, quotaDisplay: "used" }), + ).toBe(`5h 12%${sep}7d 17%`); + }); + + it("prints both status-line boundaries", () => { + const boundary = readyQuota([ + { label: "5h", leftPercent: 100 }, + { label: "7d", leftPercent: 0 }, + ]); + expect( + formatPromptStatusText({ quota: boundary, width: 120, quotaDisplay: "free" }), + ).toBe(`5h 100%${sep}7d 0%`); + expect( + formatPromptStatusText({ quota: boundary, width: 120, quotaDisplay: "used" }), + ).toBe(`5h 0%${sep}7d 100%`); + }); + + it("omits a window with no readable percentage in either mode", () => { + const unknown = readyQuota([{ label: "5h", leftPercent: null }]); + expect( + formatPromptStatusText({ quota: unknown, width: 120, quotaDisplay: "free" }), + ).toBe(""); + expect( + formatPromptStatusText({ quota: unknown, width: 120, quotaDisplay: "used" }), + ).toBe(""); + }); + + it("names the percentage in the details dialog", () => { + expect( + formatQuotaDetailsText(quota, Date.now(), { quotaDisplay: "free" }), + ).toContain("5h: 88% left"); + expect( + formatQuotaDetailsText(quota, Date.now(), { quotaDisplay: "used" }), + ).toContain("5h: 12% used"); + }); + + it("names both details boundaries and an unreadable window", () => { + const edges = readyQuota([ + { label: "5h", leftPercent: 100 }, + { label: "7d", leftPercent: 0 }, + { label: "code review", leftPercent: null }, + ]); + const free = formatQuotaDetailsText(edges, Date.now(), { + quotaDisplay: "free", + }); + const used = formatQuotaDetailsText(edges, Date.now(), { + quotaDisplay: "used", + }); + expect(free).toContain("5h: 100% left"); + expect(free).toContain("7d: 0% left"); + expect(used).toContain("5h: 0% used"); + expect(used).toContain("7d: 100% used"); + for (const text of [free, used]) { + expect(text).toContain("code review: unavailable"); + } + }); + + it("keeps the exhaustion tone keyed on headroom, not on the printed number", () => { + const nearlySpent = readyQuota([{ label: "5h", leftPercent: 5 }]); + const nearlyFull = readyQuota([{ label: "5h", leftPercent: 95 }]); + expect( + formatPromptStatusText({ + quota: nearlySpent, + width: 120, + quotaDisplay: "used", + }), + ).toBe("5h 95%"); + expect(resolveQuotaPromptTone(nearlySpent)).toBe("danger"); + expect( + formatPromptStatusText({ + quota: nearlyFull, + width: 120, + quotaDisplay: "used", + }), + ).toBe("5h 5%"); + expect(resolveQuotaPromptTone(nearlyFull)).toBe("normal"); + }); +}); + +describe("quota notifications follow the display mode", () => { + it("reports the configured percentage for each window", () => { + const usage = { + fiveHour: { remainingPercent: 10 }, + weekly: { remainingPercent: 72 }, + }; + expect(formatQuotaNotification(usage, "free").split("\n")).toEqual([ + "5h: 10% left | resets unavailable", + "Weekly: 72% left | resets unavailable", + ]); + expect(formatQuotaNotification(usage, "used").split("\n")).toEqual([ + "5h: 90% used | resets unavailable", + "Weekly: 28% used | resets unavailable", + ]); + }); + + it("leaves a window with no readable percentage unavailable in either mode", () => { + const usage = { fiveHour: {}, weekly: { remainingPercent: 0 } }; + expect(formatQuotaNotification(usage, "free").split("\n")).toEqual([ + "5h: unavailable", + "Weekly: 0% left | resets unavailable", + ]); + expect(formatQuotaNotification(usage, "used").split("\n")).toEqual([ + "5h: unavailable", + "Weekly: 100% used | resets unavailable", + ]); + }); +}); diff --git a/test/quota-notifications-fetch.test.ts b/test/quota-notifications-fetch.test.ts index c10e78a4..001055de 100644 --- a/test/quota-notifications-fetch.test.ts +++ b/test/quota-notifications-fetch.test.ts @@ -55,6 +55,7 @@ describe("default quota fetch path", () => { const directories: string[] = []; beforeEach(async () => { + vi.stubEnv("CODEX_AUTH_QUOTA_DISPLAY", "free"); vi.clearAllMocks(); persistUsageQuotaExhaustion.mockResolvedValue(false); persistUsageQuotaRecovery.mockResolvedValue(false); @@ -64,6 +65,7 @@ describe("default quota fetch path", () => { }); afterEach(async () => { + vi.unstubAllEnvs(); setStoragePathDirect(null); await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); }); @@ -132,7 +134,7 @@ describe("default quota fetch path", () => { await vi.waitFor(() => { expect(notify).toHaveBeenCalledWith( "Codex quota status", - "5h: 10% | resets unavailable\nWeekly: 90% | resets unavailable", + "5h: 10% left | resets unavailable\nWeekly: 90% left | resets unavailable", ); }); } finally { @@ -334,7 +336,7 @@ describe("default quota fetch path", () => { await vi.waitFor(() => { expect(notify).toHaveBeenCalledWith( "Codex quota status", - "5h: 80% | resets unavailable\nWeekly: 80% | resets unavailable", + "5h: 80% left | resets unavailable\nWeekly: 80% left | resets unavailable", ); }); } finally { diff --git a/test/quota-notifications.test.ts b/test/quota-notifications.test.ts index a0892d87..d3fa0f9e 100644 --- a/test/quota-notifications.test.ts +++ b/test/quota-notifications.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -180,8 +180,8 @@ describe("quota notification content", () => { }; const lines = formatQuotaNotification(aggregate).split("\n"); expect(lines).toHaveLength(2); - expect(lines[0]).toMatch(/^5h: 8% \| resets [^|]+$/); - expect(lines[1]).toMatch(/^Weekly: 72% \| resets [^|]+$/); + expect(lines[0]).toMatch(/^5h: 8% left \| resets [^|]+$/); + expect(lines[1]).toMatch(/^Weekly: 72% left \| resets [^|]+$/); }); it("labels the pool's earlier reset instead of pairing it with the percentage", () => { @@ -193,7 +193,7 @@ describe("quota notification content", () => { }, weekly: { remainingPercent: 72, resetAtMs: Date.now() + 120_000 }, }).split("\n"); - expect(lines[0]).toMatch(/^5h: 60% \| resets .+ \| another account resets .+$/); + expect(lines[0]).toMatch(/^5h: 60% left \| resets .+ \| another account resets .+$/); // No second reset in the pool, so no second clause. expect(lines[1]).not.toContain("another account"); }); @@ -202,7 +202,7 @@ describe("quota notification content", () => { expect(formatQuotaNotification({ fiveHour: {}, weekly: { remainingPercent: 20 }, - })).toBe("5h: unavailable\nWeekly: 20% | resets unavailable"); + })).toBe("5h: unavailable\nWeekly: 20% left | resets unavailable"); }); }); @@ -357,7 +357,12 @@ describe("quota threshold transitions", () => { describe("quota monitor lifecycle", () => { const tempDirectories: string[] = []; + beforeEach(() => { + vi.stubEnv("CODEX_AUTH_QUOTA_DISPLAY", "free"); + }); + afterEach(async () => { + vi.unstubAllEnvs(); vi.useRealTimers(); setStoragePathDirect(null); await Promise.all(tempDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); @@ -551,7 +556,11 @@ describe("quota monitor lifecycle", () => { } }); - it("notifies when only the weekly window crosses a threshold", async () => { + it.each([ + ["free", "50% left", "20% left"], + ["used", "50% used", "80% used"], + ])("notifies in %s mode when only the weekly window crosses a threshold", async (mode, fiveHourPercent, weeklyPercent) => { + vi.stubEnv("CODEX_AUTH_QUOTA_DISPLAY", mode); const directory = await mkdtemp(join(tmpdir(), "quota-monitor-")); tempDirectories.push(directory); const storagePath = join(directory, "accounts.json"); @@ -584,7 +593,7 @@ describe("quota monitor lifecycle", () => { await vi.waitFor(() => { expect(notify).toHaveBeenCalledWith( "Codex quota status", - "5h: 50% | resets unavailable\nWeekly: 20% | resets unavailable", + `5h: ${fiveHourPercent} | resets unavailable\nWeekly: ${weeklyPercent} | resets unavailable`, ); }); } finally { diff --git a/test/quota-overview.test.ts b/test/quota-overview.test.ts new file mode 100644 index 00000000..347f766e --- /dev/null +++ b/test/quota-overview.test.ts @@ -0,0 +1,877 @@ +import { describe, expect, it } from "vitest"; + +import { getQuotaStatus } from "../lib/config.js"; +import { PluginConfigSchema } from "../lib/schemas.js"; +import { + computePoolAllotment, + computeWeightedLeftPercent, + formatCompactDuration, + formatQuotaOverviewCandidates, + formatQuotaOverviewText, + formatQuotaResetsCandidates, + isPoolFullySpent, + orderOverviewAccounts, + resolveAccountName, + resolveGoverningWindow, + resolveQuotaOverviewRecovery, + resolveQuotaOverviewTonePercent, + type QuotaOverviewAccount, + type QuotaOverviewOptions, +} from "../lib/quota-overview.js"; + +const NOW = Date.UTC(2026, 8, 17, 12, 0, 0); +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const allOff: Omit = { + layout: "count", + names: "number", + order: "number", + multipliers: false, + allotment: false, + resetTimes: "never", + resetCredits: false, + recovery: false, + now: NOW, +}; + +function options(overrides: Partial = {}): QuotaOverviewOptions { + return { mode: "free", ...allOff, ...overrides }; +} + +/** The pool from the issue: a 5x seat, a spent 20x seat, and a 1x seat. */ +const pool: QuotaOverviewAccount[] = [ + { + index: 1, + planType: "self_serve_business_prolite", + windows: [{ leftPercent: 87, resetAtMs: NOW + 2 * DAY }], + }, + { + index: 2, + planType: "pro", + resetCredits: 1, + windows: [ + { leftPercent: 100, resetAtMs: NOW + 4 * HOUR }, + { leftPercent: 0, resetAtMs: NOW + 3 * DAY }, + ], + }, + { + index: 3, + planType: "plus", + windows: [{ leftPercent: 88, resetAtMs: NOW + 5 * DAY }], + }, +]; + +/** Nothing left anywhere, and two accounts holding a redeemable reset. */ +const spentPool: QuotaOverviewAccount[] = [ + { + index: 1, + planType: "plus", + email: "damian@nowaker.net", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 3 * DAY }], + }, + { + index: 2, + planType: "plus", + email: "work@example.com", + resetCredits: 2, + windows: [{ leftPercent: 0, resetAtMs: NOW + 4 * DAY }], + }, + { + index: 3, + planType: "plus", + email: "spare@example.com", + windows: [{ leftPercent: 0, resetAtMs: NOW + 5 * DAY }], + }, +]; + +describe("formatCompactDuration", () => { + it("floors to the largest unit that fits", () => { + expect(formatCompactDuration(3 * DAY)).toBe("3d"); + expect(formatCompactDuration(2 * DAY + 20 * HOUR)).toBe("2d"); + expect(formatCompactDuration(5 * HOUR)).toBe("5h"); + expect(formatCompactDuration(90 * 60 * 1000)).toBe("1h"); + expect(formatCompactDuration(15 * 60 * 1000)).toBe("15m"); + }); + + it("never counts a future reset as zero away", () => { + expect(formatCompactDuration(1)).toBe("1m"); + }); + + it("drops a reset already in the past", () => { + expect(formatCompactDuration(0)).toBeUndefined(); + expect(formatCompactDuration(-DAY)).toBeUndefined(); + expect(formatCompactDuration(Number.NaN)).toBeUndefined(); + }); +}); + +describe("resolveGoverningWindow", () => { + it("picks the window with the least headroom", () => { + expect(resolveGoverningWindow(pool[1]!)?.leftPercent).toBe(0); + }); + + it("breaks a tie on the window that blocks for longer", () => { + const account: QuotaOverviewAccount = { + index: 1, + windows: [ + { leftPercent: 0, resetAtMs: NOW + 4 * HOUR }, + { leftPercent: 0, resetAtMs: NOW + 3 * DAY }, + ], + }; + expect(resolveGoverningWindow(account)?.resetAtMs).toBe(NOW + 3 * DAY); + }); + + it("ignores windows with no readable percentage", () => { + const account: QuotaOverviewAccount = { + index: 1, + windows: [{ resetAtMs: NOW + HOUR }, { leftPercent: 40 }], + }; + expect(resolveGoverningWindow(account)?.leftPercent).toBe(40); + }); + + it("returns nothing when no window is readable", () => { + expect(resolveGoverningWindow({ index: 1, windows: [] })).toBeUndefined(); + }); +}); + +describe("computeWeightedLeftPercent", () => { + it("weighs each account by its plan allotment", () => { + // (5*87 + 20*0 + 1*88) / 26 = 20.1 + expect(computeWeightedLeftPercent(pool)).toBe(20); + }); + + it("differs from the unweighted mean, which is the point", () => { + const unweighted = Math.round((87 + 0 + 88) / 3); + expect(unweighted).toBe(58); + expect(computeWeightedLeftPercent(pool)).not.toBe(unweighted); + }); + + it("weighs a plan that states no ratio as one baseline seat", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "enterprise", windows: [{ leftPercent: 50 }] }, + { index: 2, planType: "plus", windows: [{ leftPercent: 100 }] }, + ]; + expect(computeWeightedLeftPercent(accounts)).toBe(75); + }); + + it("leaves an unreadable account out rather than counting it as full", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 40 }] }, + { index: 2, planType: "plus", windows: [] }, + ]; + expect(computeWeightedLeftPercent(accounts)).toBe(40); + }); + + it("reports nothing when the whole pool is unreadable", () => { + expect(computeWeightedLeftPercent([{ index: 1, windows: [] }])).toBeUndefined(); + }); +}); + +describe("computePoolAllotment", () => { + it("adds up the seats the percentage is averaged over", () => { + expect(computePoolAllotment(pool)).toBe(26); + }); + + it("counts exactly the accounts the mean counts", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "pro", windows: [{ leftPercent: 40 }] }, + { index: 2, planType: "pro", windows: [] }, + ]; + expect(computePoolAllotment(accounts)).toBe(20); + }); + + it("reports nothing for an unreadable pool", () => { + expect(computePoolAllotment([{ index: 1, windows: [] }])).toBeUndefined(); + }); +}); + +describe("resolveQuotaOverviewRecovery", () => { + it("ignores a refill that leaves the account's governing window spent", () => { + // Account 2's 5h window is already full, so the earliest reset that + // changes anything is its weekly one three days out. + const recovery = resolveQuotaOverviewRecovery(pool, NOW); + expect(recovery?.atMs).toBe(NOW + 2 * DAY); + }); + + it("measures how far the pool total moves", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 0, resetAtMs: NOW + DAY }] }, + { index: 2, planType: "plus", windows: [{ leftPercent: 50 }] }, + ]; + // 25% now, 75% once account 1 refills. + expect(resolveQuotaOverviewRecovery(accounts, NOW)).toEqual({ + deltaPercent: 50, + atMs: NOW + DAY, + }); + }); + + it("reports nothing when no window has a future reset", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 0, resetAtMs: NOW - DAY }] }, + ]; + expect(resolveQuotaOverviewRecovery(accounts, NOW)).toBeUndefined(); + }); + + it("reports nothing when every account is already full", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 100, resetAtMs: NOW + DAY }] }, + ]; + expect(resolveQuotaOverviewRecovery(accounts, NOW)).toBeUndefined(); + }); +}); + +describe("isPoolFullySpent", () => { + it("is true only when nothing has headroom", () => { + expect(isPoolFullySpent(spentPool)).toBe(true); + expect(isPoolFullySpent(pool)).toBe(false); + }); + + it("is not decided by an account nobody could read", () => { + expect( + isPoolFullySpent([ + { index: 1, windows: [{ leftPercent: 0 }] }, + { index: 2, windows: [] }, + ]), + ).toBe(true); + expect(isPoolFullySpent([{ index: 1, windows: [] }])).toBe(false); + }); +}); + +describe("orderOverviewAccounts", () => { + const indices = (order: QuotaOverviewOptions["order"]) => + orderOverviewAccounts(pool, order).map((account) => account.index); + + it("keeps account order by default", () => { + expect(indices("number")).toEqual([1, 2, 3]); + }); + + it("puts the account rotation is about to give up on first", () => { + expect(indices("most-used")).toEqual([2, 1, 3]); + expect(indices("least-used")).toEqual([3, 1, 2]); + }); + + it("orders by the governing window's reset in both directions", () => { + expect(indices("renewing-earliest")).toEqual([1, 2, 3]); + expect(indices("renewing-latest")).toEqual([3, 2, 1]); + }); + + it("breaks every tie on the account number, so nothing shuffles", () => { + const tied: QuotaOverviewAccount[] = [ + { index: 3, planType: "plus", windows: [{ leftPercent: 50 }] }, + { index: 1, planType: "plus", windows: [{ leftPercent: 50 }] }, + { index: 2, planType: "plus", windows: [{ leftPercent: 50 }] }, + ]; + for (const order of ["most-used", "least-used", "renewing-latest"] as const) { + expect(orderOverviewAccounts(tied, order).map((a) => a.index)).toEqual([ + 1, 2, 3, + ]); + } + }); + + it("sorts an account with no known reset last, whichever way it is asked", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 10 }] }, + { index: 2, planType: "plus", windows: [{ leftPercent: 10, resetAtMs: NOW + DAY }] }, + ]; + expect( + orderOverviewAccounts(accounts, "renewing-earliest").map((a) => a.index), + ).toEqual([2, 1]); + expect( + orderOverviewAccounts(accounts, "renewing-latest").map((a) => a.index), + ).toEqual([2, 1]); + }); +}); + +describe("resolveAccountName", () => { + it("numbers an account the way codex-switch does", () => { + expect(resolveAccountName(pool[0]!, "number")).toBe("#1"); + }); + + it("shows nothing at all when asked for nothing", () => { + expect(resolveAccountName(pool[0]!, "none")).toBeUndefined(); + }); + + it("prefers a label the user set", () => { + expect( + resolveAccountName({ ...spentPool[0]!, label: "work" }, "label"), + ).toBe("work"); + }); + + it("falls back to the part of the email a person says out loud", () => { + expect(resolveAccountName(spentPool[0]!, "label")).toBe("damian"); + }); + + it("masks that fallback when emails are masked", () => { + expect(resolveAccountName(spentPool[0]!, "label", true)).toBe("da***"); + }); + + it("treats an email stored as the label as an email", () => { + expect( + resolveAccountName({ index: 4, label: "someone@example.com", windows: [] }, "label"), + ).toBe("someone"); + }); + + it("falls back to the number rather than rendering an empty segment", () => { + expect(resolveAccountName({ index: 7, windows: [] }, "label")).toBe("#7"); + }); +}); + +describe("formatQuotaOverviewText", () => { + const breakdown = { + layout: "accounts", + resetTimes: "low", + } as const; + + it("renders the fullest form as headroom left", () => { + expect( + formatQuotaOverviewText( + pool, + options({ ...breakdown, multipliers: true, resetCredits: true }), + ), + ).toBe("20%: #1 5x 87%, #2 20x 0% 3d 1r, #3 1x 88%"); + }); + + it("inverts every percentage under `used`", () => { + expect( + formatQuotaOverviewText( + pool, + options({ + ...breakdown, + mode: "used", + multipliers: true, + resetCredits: true, + }), + ), + ).toBe("80%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12%"); + }); + + it("drops the badges without dropping the accounts", () => { + expect( + formatQuotaOverviewText(pool, options({ ...breakdown, mode: "used" })), + ).toBe("80%: #1 13%, #2 100% 3d, #3 12%"); + }); + + it("collapses to a count when the breakdown is switched off", () => { + expect(formatQuotaOverviewText(pool, options({ mode: "used" }))).toBe( + "80%: 3 accounts", + ); + }); + + it("adds the recovery clause with the sign the reading moves in", () => { + // Account 1 refills first, from 87% to full: a 5x seat moving 13 points + // lifts a pool weighted 5:20:1 by three. + expect(formatQuotaOverviewText(pool, options({ recovery: true }))).toBe( + "20%: 3 accounts, +3% in 2d", + ); + expect( + formatQuotaOverviewText(pool, options({ mode: "used", recovery: true })), + ).toBe("80%: 3 accounts, -3% in 2d"); + }); + + it("prints a reset only for an account near exhaustion", () => { + expect(formatQuotaOverviewText(pool, options(breakdown))).toBe( + "20%: #1 87%, #2 0% 3d, #3 88%", + ); + }); + + it("prints every reset when asked, because 90% spent is not one situation", () => { + expect( + formatQuotaOverviewText( + pool, + options({ layout: "accounts", resetTimes: "always" }), + ), + ).toBe("20%: #1 87% 2d, #2 0% 3d, #3 88% 5d"); + }); + + it("prints no reset at all when asked for none", () => { + expect( + formatQuotaOverviewText( + pool, + options({ layout: "accounts", resetTimes: "never" }), + ), + ).toBe("20%: #1 87%, #2 0%, #3 88%"); + }); + + it("states what the pool adds up to when asked", () => { + expect( + formatQuotaOverviewText(pool, options({ ...breakdown, allotment: true })), + ).toBe("20% of 26x: #1 87%, #2 0% 3d, #3 88%"); + }); + + it("drops the account names when asked, leaving position to identify them", () => { + expect( + formatQuotaOverviewText( + pool, + options({ ...breakdown, mode: "used", names: "none" }), + ), + ).toBe("80%: 13%, 100% 3d, 12%"); + }); + + it("names accounts the way their owner does", () => { + expect( + formatQuotaOverviewText( + spentPool, + options({ layout: "accounts", names: "label", mode: "used" }), + ), + ).toBe("100%: damian 100%, work 100%, spare 100%"); + }); + + it("reorders the accounts without renumbering them", () => { + expect( + formatQuotaOverviewText( + pool, + options({ ...breakdown, mode: "used", order: "most-used" }), + ), + ).toBe("80%: #2 100% 3d, #1 13%, #3 12%"); + }); + + it("omits a zero reset-credit count", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", resetCredits: 0, windows: [{ leftPercent: 40 }] }, + ]; + expect( + formatQuotaOverviewText( + accounts, + options({ layout: "accounts", resetCredits: true }), + ), + ).toBe("40%: #1 40%"); + }); + + it("says `1 account` rather than `1 accounts`", () => { + const accounts: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 40 }] }, + ]; + expect(formatQuotaOverviewText(accounts, options())).toBe("40%: 1 account"); + }); + + it("renders nothing when the pool cannot be read", () => { + expect(formatQuotaOverviewText([], options({ layout: "accounts" }))).toBe(""); + }); +}); + +describe("formatQuotaOverviewText with the aggregate layout", () => { + /** Two accounts with room and three spent, which is what grouping is for. */ + const mixed: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 88, resetAtMs: NOW + 3 * DAY }] }, + { index: 2, planType: "plus", windows: [{ leftPercent: 50, resetAtMs: NOW + 4 * DAY }] }, + { + index: 3, + planType: "plus", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 3 * DAY }], + }, + { index: 4, planType: "plus", windows: [{ leftPercent: 0, resetAtMs: NOW + 4 * DAY }] }, + { index: 5, planType: "plus", windows: [{ leftPercent: 0, resetAtMs: NOW + 5 * DAY }] }, + ]; + + it("says a shared percentage once and keeps what differs", () => { + expect( + formatQuotaOverviewText( + mixed, + options({ + layout: "aggregate", + mode: "used", + resetTimes: "always", + resetCredits: true, + }), + ), + ).toBe("72%: 12% 3d, 50% 4d, 100% 3d 1r 4d 5d"); + }); + + it("counts a group whose annotations would not reveal its size", () => { + const withoutReset = mixed.map((account) => + account.index === 3 + ? { ...account, resetCredits: undefined, windows: [{ leftPercent: 0 }] } + : account, + ); + expect( + formatQuotaOverviewText( + withoutReset, + options({ layout: "aggregate", mode: "used", resetTimes: "always" }), + ), + ).toBe("72%: 12% 3d, 50% 4d, 100% x3 4d 5d"); + }); + + it("never counts a group of one", () => { + expect( + formatQuotaOverviewText( + pool, + options({ layout: "aggregate", mode: "used", resetTimes: "low" }), + ), + ).toBe("80%: 13%, 100% 3d, 12%"); + }); +}); + +describe("formatQuotaOverviewCandidates", () => { + it("degrades detail before it degrades the pool total", () => { + const candidates = formatQuotaOverviewCandidates( + pool, + options({ + layout: "accounts", + multipliers: true, + resetTimes: "low", + resetCredits: true, + recovery: true, + }), + ); + expect(candidates[0]).toContain("5x"); + expect(candidates[0]).toContain(" in "); + expect(candidates.at(-1)).toBe("20%"); + for (const candidate of candidates) expect(candidate.startsWith("20%")).toBe(true); + expect(new Set(candidates).size).toBe(candidates.length); + const firstWithoutBreakdown = candidates.findIndex((candidate) => + candidate.includes("accounts"), + ); + const lastWithBreakdown = candidates.findLastIndex((candidate) => + candidate.includes("#1"), + ); + expect(lastWithBreakdown).toBeLessThan(firstWithoutBreakdown); + }); + + it("never reintroduces a switch that is off", () => { + const candidates = formatQuotaOverviewCandidates( + pool, + options({ layout: "accounts", resetTimes: "low" }), + ); + for (const candidate of candidates) { + expect(candidate).not.toContain("5x"); + expect(candidate).not.toContain("1r"); + expect(candidate).not.toContain(" in "); + expect(candidate).not.toContain(" of "); + } + }); + + it("gives up the word `in` before it gives up the recovery clause", () => { + const candidates = formatQuotaOverviewCandidates( + pool, + options({ recovery: true }), + ); + const wordy = candidates.indexOf("20%: 3 accounts, +3% in 2d"); + const terse = candidates.indexOf("20%: 3 accounts, +3% 2d"); + const without = candidates.indexOf("20%: 3 accounts"); + expect(wordy).toBeGreaterThanOrEqual(0); + expect(terse).toBeGreaterThan(wordy); + expect(without).toBeGreaterThan(terse); + }); + + it("shortens the count word before dropping it, then drops it", () => { + const candidates = formatQuotaOverviewCandidates(pool, options()); + expect(candidates).toEqual([ + "20%: 3 accounts", + "20%: 3 acct.", + "20%: 3", + "20%", + ]); + }); + + it("gives up the pool allotment before any account detail", () => { + const candidates = formatQuotaOverviewCandidates( + pool, + options({ layout: "accounts", allotment: true }), + ); + const withAllotment = candidates.indexOf("20% of 26x: #1 87%, #2 0%, #3 88%"); + const withoutAllotment = candidates.indexOf("20%: #1 87%, #2 0%, #3 88%"); + const count = candidates.indexOf("20% of 26x: 3 accounts"); + expect(withAllotment).toBeGreaterThanOrEqual(0); + expect(withoutAllotment).toBe(withAllotment + 1); + expect(count).toBeGreaterThan(withoutAllotment); + }); + + it("offers an unnamed breakdown as the last rung above the count", () => { + const candidates = formatQuotaOverviewCandidates( + pool, + options({ layout: "accounts", resetTimes: "never" }), + ); + const unnamed = candidates.indexOf("20%: 87%, 0%, 88%"); + const count = candidates.indexOf("20%: 3 accounts"); + expect(unnamed).toBeGreaterThanOrEqual(0); + expect(unnamed).toBeLessThan(count); + }); + + it("refuses to drop the names when position no longer identifies an account", () => { + for (const overrides of [ + { order: "most-used" as const }, + {}, + ]) { + const accounts = + "order" in overrides + ? pool + : [...pool, { index: 4, planType: "plus", windows: [] }]; + const candidates = formatQuotaOverviewCandidates( + accounts, + options({ layout: "accounts", resetTimes: "never", ...overrides }), + ); + expect(candidates.some((candidate) => /: \d+%/.test(candidate))).toBe(false); + } + }); + + it("refuses to drop the names when the account numbers skip one", () => { + // A deduplicated or disabled seat leaves a pool reading #1 and #3: an + // unnamed `60%, 10%` would pin #3's figure on a #2 that is not there. + const gapped: QuotaOverviewAccount[] = [ + { index: 1, planType: "plus", windows: [{ leftPercent: 60 }] }, + { index: 3, planType: "plus", windows: [{ leftPercent: 10 }] }, + ]; + const candidates = formatQuotaOverviewCandidates( + gapped, + options({ layout: "accounts", names: "none", resetTimes: "never" }), + ); + // An explicit names:"none" is the user's choice; the ladder must not + // reach it on its own when position would misattribute. With the + // default number names, the breakdown keeps its `#n` and the count is + // the next thing down. + const named = formatQuotaOverviewCandidates( + gapped, + options({ layout: "accounts", resetTimes: "never" }), + ); + expect(candidates[0]).toBe("35%: 60%, 10%"); + expect(named).toContain("35%: #1 60%, #3 10%"); + expect(named.some((candidate) => /: \d+%/.test(candidate))).toBe(false); + expect(named).toContain("35%: 2 accounts"); + }); + + it("counts the whole pool, not just the accounts that could be read", () => { + const candidates = formatQuotaOverviewCandidates( + [...pool, { index: 4, planType: "plus", windows: [] }], + options(), + ); + expect(candidates).toContain("20%: 4 accounts"); + expect(candidates).not.toContain("20%: 3 accounts"); + }); + + it("shortens a long name to the number before giving up on names", () => { + const candidates = formatQuotaOverviewCandidates( + spentPool, + options({ layout: "accounts", names: "label", mode: "used" }), + ); + const named = candidates.indexOf("100%: damian 100%, work 100%, spare 100%"); + const numbered = candidates.indexOf("100%: #1 100%, #2 100%, #3 100%"); + expect(named).toBe(0); + expect(numbered).toBeGreaterThan(named); + }); +}); + +describe("formatQuotaResetsCandidates", () => { + it("lists the redeemable credits latest reset first", () => { + expect(formatQuotaResetsCandidates(spentPool, { now: NOW })[0]).toBe( + "Free resets: 4d 2r work@example.com, 3d 1r damian@nowaker.net", + ); + }); + + it("says nothing while any account still has headroom", () => { + expect(formatQuotaResetsCandidates(pool, { now: NOW })).toEqual([]); + }); + + it("says nothing when the spent pool has no credit to redeem", () => { + const withoutCredits = spentPool.map((account) => ({ + ...account, + resetCredits: undefined, + })); + expect(formatQuotaResetsCandidates(withoutCredits, { now: NOW })).toEqual([]); + }); + + it("gives up the word `Free` before any account detail", () => { + const candidates = formatQuotaResetsCandidates(spentPool, { now: NOW }); + expect(candidates[1]).toBe( + "Resets: 4d 2r work@example.com, 3d 1r damian@nowaker.net", + ); + expect(candidates[2]).toBe("Resets: 4d 2r work, 3d 1r damian"); + expect(candidates[3]).toBe("Resets: 4d 2r #2, 3d 1r #1"); + }); + + it("keeps the countdown until every identity form has been tried", () => { + const candidates = formatQuotaResetsCandidates(spentPool, { now: NOW }); + const lastWithCountdown = candidates.findLastIndex((candidate) => + candidate.includes("4d"), + ); + const firstWithout = candidates.findIndex( + (candidate) => candidate.startsWith("Resets:") && !candidate.includes("4d"), + ); + expect(lastWithCountdown).toBeLessThan(firstWithout); + }); + + it("ends on a bare count rather than on nothing", () => { + expect(formatQuotaResetsCandidates(spentPool, { now: NOW }).at(-1)).toBe( + "Resets: 2", + ); + }); + + it("drops a credit count that is 1 everywhere, since it is not news", () => { + const single = spentPool.map((account) => + account.index === 2 ? { ...account, resetCredits: 1 } : account, + ); + const candidates = formatQuotaResetsCandidates(single, { now: NOW }); + expect(candidates).toContain("Resets: 4d work@example.com, 3d damian@nowaker.net"); + }); + + it("masks the address when emails are masked", () => { + expect( + formatQuotaResetsCandidates(spentPool, { now: NOW, maskEmail: true })[0], + ).toBe("Free resets: 4d 2r wo***@example.com, 3d 1r da***@nowaker.net"); + }); + + it("numbers the accounts when the pool line is configured that way", () => { + const candidates = formatQuotaResetsCandidates(spentPool, { + now: NOW, + names: "number", + }); + expect(candidates[0]).toBe("Free resets: 4d 2r #2, 3d 1r #1"); + for (const candidate of candidates) { + expect(candidate).not.toContain("@"); + } + }); + + it("names no account when the pool line is configured nameless", () => { + const candidates = formatQuotaResetsCandidates(spentPool, { + now: NOW, + names: "none", + }); + expect(candidates[0]).toBe("Free resets: 4d 2r, 3d 1r"); + expect(candidates.at(-1)).toBe("Resets: 2"); + for (const candidate of candidates) { + expect(candidate).not.toContain("@"); + expect(candidate).not.toContain("#"); + } + }); +}); + +describe("resolveQuotaOverviewTonePercent", () => { + it("reports the healthiest account, not the worst", () => { + expect(resolveQuotaOverviewTonePercent(pool)).toBe(88); + }); + + it("reports nothing for an unreadable pool", () => { + expect(resolveQuotaOverviewTonePercent([])).toBeUndefined(); + }); +}); + +describe("getQuotaStatus", () => { + it("leaves every existing install on the account it is serving from", () => { + expect(getQuotaStatus({}).screens).toEqual(["active"]); + }); + + it("shows each account with its reset once the pool view is on", () => { + expect(getQuotaStatus({ quotaStatus: { mode: "overview" } })).toEqual({ + screens: ["overview"], + rotateMs: 5_000, + layout: "accounts", + accountNames: "number", + order: "number", + multipliers: false, + allotment: false, + resetTimes: "low", + resetCredits: false, + recovery: false, + rows: 1, + showFor: "always", + }); + }); + + it("honours every switch independently", () => { + expect( + getQuotaStatus({ + quotaStatus: { + mode: ["overview", "resets"], + rotateMs: 8_000, + layout: "aggregate", + accountNames: "label", + order: "most-used", + multipliers: true, + allotment: true, + resetTimes: "always", + resetCredits: true, + recovery: true, + rows: 2, + showFor: "codex-models", + }, + }), + ).toEqual({ + screens: ["overview", "resets"], + rotateMs: 8_000, + layout: "aggregate", + accountNames: "label", + order: "most-used", + multipliers: true, + allotment: true, + resetTimes: "always", + resetCredits: true, + recovery: true, + rows: 2, + showFor: "codex-models", + }); + }); + + it("collapses a repeated screen so it cannot come up twice as often", () => { + expect( + getQuotaStatus({ quotaStatus: { mode: ["overview", "overview", "active"] } }) + .screens, + ).toEqual(["overview", "active"]); + }); + + it("falls back to the serving account when no screen survives", () => { + expect(getQuotaStatus({ quotaStatus: { mode: [] } }).screens).toEqual([ + "active", + ]); + }); + + it("keeps the rotation slow enough to read", () => { + expect(getQuotaStatus({ quotaStatus: { rotateMs: 10 } }).rotateMs).toBe(1_000); + }); + + it("clamps the row count to something a prompt can hold", () => { + expect(getQuotaStatus({ quotaStatus: { rows: 99 } }).rows).toBe(4); + expect(getQuotaStatus({ quotaStatus: { rows: 1 } }).rows).toBe(1); + }); + + it("reads the earlier boolean spelling of the reset and layout switches", () => { + // A config written against the first build of this feature must not lose + // its meaning, and must not fail validation either. + expect(getQuotaStatus({ quotaStatus: { resetTimes: true } }).resetTimes).toBe( + "low", + ); + expect(getQuotaStatus({ quotaStatus: { resetTimes: false } }).resetTimes).toBe( + "never", + ); + expect(getQuotaStatus({ quotaStatus: { accounts: false } }).layout).toBe( + "count", + ); + expect( + getQuotaStatus({ quotaStatus: { accounts: false, layout: "accounts" } }) + .layout, + ).toBe("accounts"); + }); + + it("ignores a value the schema would not have accepted", () => { + expect( + PluginConfigSchema.safeParse({ quotaStatus: { mode: "overview" } }).success, + ).toBe(true); + expect( + PluginConfigSchema.safeParse({ quotaStatus: { mode: ["active", "resets"] } }) + .success, + ).toBe(true); + expect( + PluginConfigSchema.safeParse({ quotaStatus: { mode: "summary" } }).success, + ).toBe(false); + expect( + PluginConfigSchema.safeParse({ quotaStatus: { multipliers: "yes" } }).success, + ).toBe(false); + expect( + PluginConfigSchema.safeParse({ quotaStatus: { rows: "two" } }).success, + ).toBe(false); + // The legacy boolean stays acceptable so one stale value cannot reset + // every other setting in the file. + expect( + PluginConfigSchema.safeParse({ quotaStatus: { resetTimes: true } }).success, + ).toBe(true); + }); + + it("is read from the config file alone, never from the environment", () => { + process.env.CODEX_AUTH_QUOTA_STATUS = "overview"; + try { + expect(getQuotaStatus({}).screens).toEqual(["active"]); + } finally { + delete process.env.CODEX_AUTH_QUOTA_STATUS; + } + }); +}); diff --git a/test/standalone-cli.test.ts b/test/standalone-cli.test.ts index 0e835640..67ec4e73 100644 --- a/test/standalone-cli.test.ts +++ b/test/standalone-cli.test.ts @@ -1,5 +1,5 @@ /// -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,10 +17,11 @@ vi.mock("../scripts/install-oc-codex-multi-auth-core.js", async (importOriginal) return { storageMod, usageMod, warmReqMod, warmMod, recoveryMod }; }, loadLimitsRuntime: async () => { - const [storageMod, usageMod, loggerMod] = await Promise.all([ + const [storageMod, usageMod, loggerMod, configMod] = await Promise.all([ import("../lib/storage.js"), import("../lib/codex-usage.js"), import("../lib/logger.js"), + import("../lib/config.js"), ]); - return { storageMod, usageMod, loggerMod }; + return { storageMod, usageMod, loggerMod, configMod }; }, ...options, }); @@ -59,10 +60,24 @@ async function seedPool(home: string, accounts: unknown[]) { ); } +const QUOTA_DISPLAY_ENV = "CODEX_AUTH_QUOTA_DISPLAY"; + describe("standalone oc-codex-multi-auth CLI commands", () => { let tempHome: string | null = null; + let previousQuotaDisplay: string | undefined; + + // These cases load the real `dist/lib/config.js`, whose config path is the + // developer's own `~/.opencode`, not the temp home handed to `runInstaller`. + // Pinning the env override - which outranks the file - keeps a machine that + // has opted into `used` from failing every `% left` assertion below. + beforeEach(() => { + previousQuotaDisplay = process.env[QUOTA_DISPLAY_ENV]; + process.env[QUOTA_DISPLAY_ENV] = "free"; + }); afterEach(async () => { + if (previousQuotaDisplay === undefined) delete process.env[QUOTA_DISPLAY_ENV]; + else process.env[QUOTA_DISPLAY_ENV] = previousQuotaDisplay; vi.restoreAllMocks(); vi.unstubAllEnvs(); if (tempHome) { @@ -898,11 +913,12 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { const warmMod = await import("../lib/accounts/warm.js"); const recoveryMod = await import("../lib/accounts/warm-recovery.js"); const loggerMod = await import("../lib/logger.js"); + const configMod = await import("../lib/config.js"); const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); const result = await runInstaller([command, "--json"], { env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, loadWarmRuntime: async () => ({ storageMod, usageMod, warmReqMod, warmMod, recoveryMod }), - loadLimitsRuntime: async () => ({ storageMod, usageMod, loggerMod }), + loadLimitsRuntime: async () => ({ storageMod, usageMod, loggerMod, configMod }), }); const stored = JSON.parse(await readFile(join(tempHome, ".opencode", "oc-codex-multi-auth-accounts.json"), "utf-8")); expect(result.exitCode).toBe(0); @@ -1131,6 +1147,30 @@ describe("standalone oc-codex-multi-auth CLI commands", () => { expect(printed).toContain("Weekly limit: 58% left"); }); + it("limits: reports consumption instead of headroom when quotaDisplay is used", async () => { + process.env[QUOTA_DISPLAY_ENV] = "used"; + vi.resetModules(); + tempHome = await createTempHome(); + await writeAccounts(tempHome, [freshAccount()]); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => usagePayload, + text: async () => JSON.stringify(usagePayload), + } as unknown as Response); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + await runInstaller(["limits"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }); + + const printed = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(printed).toContain("5h limit: 18% used"); + expect(printed).toContain("Weekly limit: 42% used"); + expect(printed).not.toContain("% left"); + }); + it("limits: --tag only contacts matching accounts", async () => { vi.resetModules(); tempHome = await createTempHome(); diff --git a/test/tui-config-reload.test.ts b/test/tui-config-reload.test.ts new file mode 100644 index 00000000..ccf3909d --- /dev/null +++ b/test/tui-config-reload.test.ts @@ -0,0 +1,177 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +const home = vi.hoisted(() => ({ path: "" })); +vi.mock("node:os", async (original) => ({ + ...(await original()), + homedir: () => home.path, +})); + +type TuiModule = typeof import("../tui.js"); + +describe("TUI status configuration reload", () => { + let configPath: string; + let read: TuiModule["readPromptStatusOptions"]; + let same: TuiModule["samePromptStatusOptions"]; + + const writeConfig = (config: unknown): void => { + writeFileSync(configPath, JSON.stringify(config)); + }; + + // Imported ONCE, which is also what the behaviour under test requires: a + // reload that only works by re-importing the module proves nothing, since + // the running TUI never re-imports. The config path is captured when + // `lib/config.ts` is first evaluated, so the home directory is fixed before + // that import and each test rewrites the same file underneath it. + beforeAll(async () => { + home.path = mkdtempSync(join(tmpdir(), "tui-config-reload-")); + mkdirSync(join(home.path, ".opencode")); + configPath = join(home.path, ".opencode", "openai-codex-auth-config.json"); + const tui: TuiModule = await import("../tui.js"); + read = tui.readPromptStatusOptions; + same = tui.samePromptStatusOptions; + }); + + afterAll(() => { + rmSync(home.path, { recursive: true, force: true }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("reports the shipped defaults when nothing is configured", () => { + writeConfig({}); + expect(read()).toMatchObject({ + quotaDisplay: "free", + quotaStatus: { + screens: ["active"], + rotateMs: 5_000, + layout: "accounts", + accountNames: "number", + order: "number", + multipliers: false, + allotment: false, + resetTimes: "low", + resetCredits: false, + recovery: false, + rows: 1, + showFor: "always", + }, + }); + }); + + it("picks up an edit to the file without reloading the module", () => { + writeConfig({ quotaStatus: { mode: "active" } }); + expect(read().quotaStatus.screens).toEqual(["active"]); + + writeConfig({ + quotaDisplay: "used", + quotaStatus: { mode: "overview", multipliers: true, recovery: true }, + }); + const after = read(); + expect(after.quotaStatus.screens).toEqual(["overview"]); + expect(after.quotaStatus.multipliers).toBe(true); + expect(after.quotaStatus.recovery).toBe(true); + expect(after.quotaDisplay).toBe("used"); + }); + + it("follows a switch back to the serving-account line", () => { + writeConfig({ quotaStatus: { mode: "overview" } }); + expect(read().quotaStatus.screens).toEqual(["overview"]); + + writeConfig({ quotaStatus: { mode: "active" } }); + expect(read().quotaStatus.screens).toEqual(["active"]); + }); + + it("picks up a screen list being turned into a rotation", () => { + writeConfig({ quotaStatus: { mode: "overview" } }); + expect(read().quotaStatus.screens).toEqual(["overview"]); + + writeConfig({ quotaStatus: { mode: ["overview", "resets"], rotateMs: 3_000 } }); + const after = read(); + expect(after.quotaStatus.screens).toEqual(["overview", "resets"]); + expect(after.quotaStatus.rotateMs).toBe(3_000); + }); + + it("keeps the last usable reading when the file is mid-write", () => { + writeConfig({ quotaStatus: { mode: "overview", recovery: true } }); + expect(read().quotaStatus.recovery).toBe(true); + + writeFileSync(configPath, '{"quotaStatus":{"mode":"over'); + const during = read(); + expect(during.quotaStatus.screens).toEqual(["overview"]); + expect(during.quotaStatus.recovery).toBe(true); + }); + + it("ignores an environment variable naming the screen", () => { + writeConfig({ quotaStatus: { mode: "overview" } }); + vi.stubEnv("CODEX_AUTH_QUOTA_STATUS", "active"); + // The whole object is a display preference and belongs to the person, not + // to whichever shell started this process. + expect(read().quotaStatus.screens).toEqual(["overview"]); + }); + + it("treats two readings of unchanged configuration as equal", () => { + writeConfig({ + quotaDisplay: "used", + quotaStatus: { mode: "overview", multipliers: true, recovery: true }, + }); + const first = read(); + const second = read(); + // Identity deliberately differs: every poll builds a fresh object, so + // only a field-by-field comparison can stop a re-render every tick. + expect(second).not.toBe(first); + expect(same(first, second)).toBe(true); + }); + + it("notices a change in every field that shapes the line", () => { + const base = { + quotaDisplay: "free", + maskEmail: false, + maskEmailInQuotaDetails: false, + quotaStatus: { + mode: "active", + rotateMs: 5_000, + layout: "accounts", + accountNames: "number", + order: "number", + multipliers: false, + allotment: false, + resetTimes: "low", + resetCredits: false, + recovery: false, + rows: 1, + showFor: "always", + }, + } as const; + writeConfig(base); + const first = read(); + + const changes: Array> = [ + { ...base, quotaDisplay: "used" }, + { ...base, maskEmail: true }, + { ...base, maskEmailInQuotaDetails: true }, + { ...base, quotaStatus: { ...base.quotaStatus, mode: "overview" } }, + { ...base, quotaStatus: { ...base.quotaStatus, mode: ["active", "overview"] } }, + { ...base, quotaStatus: { ...base.quotaStatus, rotateMs: 9_000 } }, + { ...base, quotaStatus: { ...base.quotaStatus, layout: "count" } }, + { ...base, quotaStatus: { ...base.quotaStatus, layout: "aggregate" } }, + { ...base, quotaStatus: { ...base.quotaStatus, accountNames: "label" } }, + { ...base, quotaStatus: { ...base.quotaStatus, order: "most-used" } }, + { ...base, quotaStatus: { ...base.quotaStatus, multipliers: true } }, + { ...base, quotaStatus: { ...base.quotaStatus, allotment: true } }, + { ...base, quotaStatus: { ...base.quotaStatus, resetTimes: "always" } }, + { ...base, quotaStatus: { ...base.quotaStatus, resetCredits: true } }, + { ...base, quotaStatus: { ...base.quotaStatus, recovery: true } }, + { ...base, quotaStatus: { ...base.quotaStatus, rows: 2 } }, + { ...base, quotaStatus: { ...base.quotaStatus, showFor: "codex-models" } }, + ]; + for (const change of changes) { + writeConfig(change); + expect(same(first, read())).toBe(false); + } + }); +}); diff --git a/test/tui-quota-overview.test.ts b/test/tui-quota-overview.test.ts new file mode 100644 index 00000000..de9ee16e --- /dev/null +++ b/test/tui-quota-overview.test.ts @@ -0,0 +1,423 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +vi.mock("../lib/codex-usage.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + ensureCodexUsageAccessToken: vi.fn(), + fetchCodexUsage: vi.fn(), + }; +}); + +import { + createUsageAccountFingerprint, + ensureCodexUsageAccessToken, + fetchCodexUsage, +} from "../lib/codex-usage.js"; +import { isPoolFullySpent } from "../lib/quota-overview.js"; +import { + getTuiQuotaOverviewCachePath, + isFreshTuiQuotaSnapshot, + isTuiQuotaOverviewSnapshot, + readTuiQuotaOverviewSnapshot, + sanitizeTuiQuotaOverviewSnapshot, + writeTuiQuotaOverviewSnapshot, + TUI_QUOTA_CACHE_VERSION, + TUI_QUOTA_OVERVIEW_CACHE_FILE, + type TuiQuotaOverviewSnapshot, + type TuiQuotaSnapshot, +} from "../lib/tui-quota-cache.js"; +import { + fetchTuiQuotaOverview, + mergeOverviewWithLatestAccount, + toOverviewAccount, + toQuotaOverviewAccounts, +} from "../lib/tui-quota-overview.js"; + +const NOW = Date.UTC(2026, 8, 17, 12, 0, 0); + +function snapshot( + overrides: Partial = {}, +): TuiQuotaOverviewSnapshot { + return { + version: TUI_QUOTA_CACHE_VERSION, + fetchedAt: NOW, + accounts: [ + { + fingerprint: "aaaa", + index: 1, + planType: "pro", + resetCredits: 1, + limits: [ + { label: "weekly", leftPercent: 0, usedPercent: 100, windowMinutes: 10080, resetAtMs: NOW + 86_400_000 }, + ], + }, + { + fingerprint: "bbbb", + index: 2, + planType: "team", + limits: [{ label: "5h", leftPercent: 60, usedPercent: 40, windowMinutes: 300 }], + }, + ], + ...overrides, + }; +} + +describe("getTuiQuotaOverviewCachePath", () => { + it("sits beside the single-account cache in the same state dir", () => { + expect(getTuiQuotaOverviewCachePath("/state")).toBe( + join("/state", TUI_QUOTA_OVERVIEW_CACHE_FILE), + ); + }); +}); + +describe("isTuiQuotaOverviewSnapshot", () => { + it("accepts a snapshot this build wrote", () => { + expect(isTuiQuotaOverviewSnapshot(snapshot())).toBe(true); + }); + + it("rejects a document from another version or shape", () => { + expect(isTuiQuotaOverviewSnapshot(snapshot({ version: 2 as never }))).toBe(false); + expect(isTuiQuotaOverviewSnapshot({ ...snapshot(), accounts: "no" })).toBe(false); + expect(isTuiQuotaOverviewSnapshot(null)).toBe(false); + expect(isTuiQuotaOverviewSnapshot(undefined)).toBe(false); + }); + + it("rejects an account with no fingerprint to attribute it to", () => { + const invalid = snapshot(); + invalid.accounts[0]!.fingerprint = " "; + expect(isTuiQuotaOverviewSnapshot(invalid)).toBe(false); + }); +}); + +describe("sanitizeTuiQuotaOverviewSnapshot", () => { + it("drops a window the plan has switched off", () => { + const withDisabled = snapshot(); + withDisabled.accounts[1]!.limits.push({ + label: "quota", + leftPercent: 100, + usedPercent: 0, + windowMinutes: 0, + }); + expect( + sanitizeTuiQuotaOverviewSnapshot(withDisabled).accounts[1]!.limits, + ).toHaveLength(1); + }); +}); + +describe("overview cache round trip", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "oc-overview-")); + vi.mocked(ensureCodexUsageAccessToken).mockReset(); + vi.mocked(fetchCodexUsage).mockReset(); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("reads back what it wrote", async () => { + const path = join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE); + await writeTuiQuotaOverviewSnapshot(snapshot(), path); + expect(await readTuiQuotaOverviewSnapshot(path)).toEqual(snapshot()); + }); + + it("treats a missing or corrupt cache as absent rather than throwing", async () => { + const path = join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE); + expect(await readTuiQuotaOverviewSnapshot(path)).toBeUndefined(); + await writeFile(path, "{ not json"); + expect(await readTuiQuotaOverviewSnapshot(path)).toBeUndefined(); + }); + + it("serves a fresh cache without touching storage or the network", async () => { + const path = join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE); + await writeTuiQuotaOverviewSnapshot(snapshot(), path); + const result = await fetchTuiQuotaOverview({ + cachePath: path, + now: NOW + 1000, + loadStorage: async () => { + throw new Error("storage must not be read while the cache is fresh"); + }, + }); + expect(result?.accounts).toHaveLength(2); + }); + + it("keeps the last known pool when every account fails to report", async () => { + const path = join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE); + await writeTuiQuotaOverviewSnapshot(snapshot(), path); + const stale = await fetchTuiQuotaOverview({ + cachePath: path, + // Well past the freshness window, so the cache cannot short-circuit. + now: NOW + 60 * 60 * 1000, + loadStorage: async () => ({ + version: 3, + accounts: [{ refreshToken: "r", enabled: true }], + activeIndex: 0, + }) as never, + }); + expect(stale?.fetchedAt).toBe(NOW); + }); + + it("reports nothing when there are no accounts at all", async () => { + expect( + await fetchTuiQuotaOverview({ + cachePath: join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE), + now: NOW, + loadStorage: async () => null, + }), + ).toBeUndefined(); + }); + + it("keeps a failed account's last reading and ages the merged snapshot", async () => { + const path = join(dir, TUI_QUOTA_OVERVIEW_CACHE_FILE); + const accountA = { + refreshToken: "refresh-a", + accountId: "account-a", + enabled: true, + }; + const accountB = { + refreshToken: "refresh-b", + accountId: "account-b", + enabled: true, + }; + // The previous poll read account B at 90% headroom: dropping it would + // judge the pool on the spent account alone and report it fully spent. + const previous = snapshot({ + accounts: [ + { + fingerprint: createUsageAccountFingerprint(accountA as never), + index: 1, + planType: "plus", + limits: [ + { + label: "weekly", + leftPercent: 0, + usedPercent: 100, + windowMinutes: 10080, + resetAtMs: NOW + 86_400_000, + }, + ], + }, + { + fingerprint: createUsageAccountFingerprint(accountB as never), + index: 2, + planType: "plus", + limits: [ + { + label: "weekly", + leftPercent: 90, + usedPercent: 10, + windowMinutes: 10080, + resetAtMs: NOW + 86_400_000, + }, + ], + }, + ], + }); + await writeTuiQuotaOverviewSnapshot(previous, path); + + vi.mocked(ensureCodexUsageAccessToken).mockResolvedValue({ + accessToken: "access-token", + refreshed: false, + persisted: false, + }); + vi.mocked(fetchCodexUsage).mockImplementation(async (params) => { + if (params.accountId === "account-b") throw new Error("transient"); + return { + rate_limit: { + primary_window: { + used_percent: 50, + limit_window_seconds: 18_000, + }, + }, + }; + }); + + const later = NOW + 60 * 60 * 1000; + const result = await fetchTuiQuotaOverview({ + cachePath: path, + now: later, + loadStorage: async () => + ({ + version: 3, + accounts: [accountA, accountB], + activeIndex: 0, + }) as never, + }); + + expect(result?.accounts).toHaveLength(2); + expect(result?.accounts[1]?.index).toBe(2); + expect(result?.accounts[1]?.limits[0]?.leftPercent).toBe(90); + expect(result && isPoolFullySpent(toQuotaOverviewAccounts(result))).toBe( + false, + ); + // The carried-over reading keeps the older fetch time, so the line is + // rendered stale rather than passing it off as current. + expect(result?.fetchedAt).toBe(NOW); + expect(result && isFreshTuiQuotaSnapshot(result, later)).toBe(false); + }); +}); + +describe("toOverviewAccount", () => { + it("keeps only the windows that govern ordinary model requests", () => { + const account = toOverviewAccount({ + fingerprint: "aaaa", + index: 3, + usage: { + planType: "pro", + credits: null, + resetCredits: { available: 2, applicableNow: 1 }, + primary: { usedPercent: 40, windowMinutes: 300 }, + secondary: { usedPercent: 10, windowMinutes: 10080 }, + codeReview: { usedPercent: 100, windowMinutes: 300 }, + additionalLimits: [], + limits: [], + }, + }); + expect(account.limits.map((limit) => limit.label)).toEqual(["5h", "weekly"]); + expect(account.limits[0]!.leftPercent).toBe(60); + expect(account.index).toBe(3); + expect(account.resetCredits).toBe(1); + }); + + it("falls back to the banked count when the applicable count is unreadable", () => { + const account = toOverviewAccount({ + fingerprint: "aaaa", + index: 1, + usage: { + planType: null, + credits: null, + resetCredits: { available: 3, applicableNow: null }, + primary: { usedPercent: 0, windowMinutes: 300 }, + secondary: {}, + codeReview: {}, + additionalLimits: [], + limits: [], + }, + }); + expect(account.resetCredits).toBe(3); + }); + + it("carries the names the status line can call an account by", () => { + const account = toOverviewAccount({ + fingerprint: "aaaa", + index: 1, + email: " damian@nowaker.net ", + label: " work ", + usage: { + planType: null, + credits: null, + resetCredits: null, + primary: { usedPercent: 0, windowMinutes: 300 }, + secondary: {}, + codeReview: {}, + additionalLimits: [], + limits: [], + }, + }); + expect(account.email).toBe("damian@nowaker.net"); + expect(account.label).toBe("work"); + }); + + it("leaves a nameless account nameless rather than inventing one", () => { + const account = toOverviewAccount({ + fingerprint: "aaaa", + index: 1, + label: " ", + usage: { + planType: null, + credits: null, + resetCredits: null, + primary: { usedPercent: 0, windowMinutes: 300 }, + secondary: {}, + codeReview: {}, + additionalLimits: [], + limits: [], + }, + }); + expect(account.email).toBeUndefined(); + expect(account.label).toBeUndefined(); + }); +}); + +describe("mergeOverviewWithLatestAccount", () => { + const latest: TuiQuotaSnapshot = { + version: TUI_QUOTA_CACHE_VERSION, + fingerprint: "bbbb", + fetchedAt: NOW + 60_000, + source: "headers", + limits: [{ label: "5h", leftPercent: 12, usedPercent: 88, windowMinutes: 300 }], + }; + + it("takes the request path's newer reading of the serving account", () => { + const merged = mergeOverviewWithLatestAccount(snapshot(), latest); + expect(merged.accounts[1]!.limits[0]!.leftPercent).toBe(12); + expect(merged.accounts[0]!.limits[0]!.leftPercent).toBe(0); + }); + + it("ignores a reading older than the poll", () => { + const merged = mergeOverviewWithLatestAccount(snapshot(), { + ...latest, + fetchedAt: NOW - 60_000, + }); + expect(merged.accounts[1]!.limits[0]!.leftPercent).toBe(60); + }); + + it("ignores an account the pool does not contain", () => { + const merged = mergeOverviewWithLatestAccount(snapshot(), { + ...latest, + fingerprint: "zzzz", + }); + expect(merged).toEqual(snapshot()); + }); + + it("ignores an empty reading rather than blanking the account", () => { + const merged = mergeOverviewWithLatestAccount(snapshot(), { + ...latest, + limits: [], + }); + expect(merged.accounts[1]!.limits).toHaveLength(1); + }); + + it("is a no-op with nothing to merge", () => { + expect(mergeOverviewWithLatestAccount(snapshot(), undefined)).toEqual(snapshot()); + }); + + it("learns an email the pool poll did not have", () => { + const merged = mergeOverviewWithLatestAccount(snapshot(), { + ...latest, + accountEmail: "damian@nowaker.net", + }); + expect(merged.accounts[1]!.email).toBe("damian@nowaker.net"); + }); +}); + +describe("toQuotaOverviewAccounts", () => { + it("hands the formatter percentages and resets, not labels", () => { + expect(toQuotaOverviewAccounts(snapshot())).toEqual([ + { + index: 1, + planType: "pro", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 86_400_000 }], + }, + { + index: 2, + planType: "team", + resetCredits: undefined, + windows: [{ leftPercent: 60, resetAtMs: undefined }], + }, + ]); + }); + + it("passes an unreadable percentage through as absent", () => { + const unreadable = snapshot(); + unreadable.accounts[0]!.limits[0]!.leftPercent = null; + expect(toQuotaOverviewAccounts(unreadable)[0]!.windows[0]!.leftPercent).toBeUndefined(); + }); +}); diff --git a/test/tui-status-slot.test.ts b/test/tui-status-slot.test.ts new file mode 100644 index 00000000..24de9c6a --- /dev/null +++ b/test/tui-status-slot.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; + +import { measureStatusSlot, showsQuotaForSession } from "../tui.js"; + +/** + * A stand-in for the host's laid-out prompt row. + * + * `measureStatusSlot` duck-types the renderer's tree rather than importing it, + * so the fake only has to carry the three things it reads. Building it here is + * also the only way to pin the assumption: if the host ever stops putting this + * slot in a two-child row, this is the test that says so. + */ +type FakeNode = { + width?: number; + height?: number; + primaryAxis?: string; + parent?: FakeNode | null; + getChildren?: () => FakeNode[]; +}; + +function promptRow(options: { + labelWidth: number; + labelHeight: number; + rowWidth: number; + /** The extra box the slot machinery inserts between node and row. */ + wrapped?: boolean; +}): FakeNode { + const label: FakeNode = { + width: options.labelWidth, + height: options.labelHeight, + primaryAxis: "row", + getChildren: () => [], + }; + const status: FakeNode = { width: 10, height: 1, getChildren: () => [] }; + const wrapper: FakeNode = { + width: 10, + height: 1, + primaryAxis: "row", + getChildren: () => [status], + }; + const row: FakeNode = { + width: options.rowWidth, + height: Math.max(options.labelHeight, 1), + primaryAxis: "row", + getChildren: () => [label, wrapper], + }; + label.parent = row; + wrapper.parent = row; + status.parent = wrapper; + if (options.wrapped === false) { + // Straight into the row, with no wrapper of its own. + status.parent = row; + row.getChildren = () => [label, status]; + } + return status; +} + +describe("measureStatusSlot", () => { + it("budgets against the prompt row rather than the terminal", () => { + // A 114-wide row inside a 165-column terminal, less the 40 the model + // label beside this line owns. + expect( + measureStatusSlot( + promptRow({ labelWidth: 40, labelHeight: 2, rowWidth: 114 }), + ), + ).toEqual({ availableChars: 74 }); + }); + + it("finds the row through the box the slot machinery inserts", () => { + expect( + measureStatusSlot( + promptRow({ + labelWidth: 40, + labelHeight: 1, + rowWidth: 114, + wrapped: false, + }), + ), + ).toEqual({ availableChars: 74 }); + }); + + it("does not let the label's own width into the budget", () => { + // The same row with a label squeezed to nothing must still report the + // same budget. A label with no room left is shrunk to whatever this line + // did not take, so reading its width would make the budget a function of + // this line's own length and ratchet it down on every render. + expect( + measureStatusSlot( + promptRow({ labelWidth: 4, labelHeight: 1, rowWidth: 114 }), + ), + ).toEqual({ availableChars: 74 }); + }); + + it("reports nothing before the first layout pass", () => { + expect( + measureStatusSlot(promptRow({ labelWidth: 0, labelHeight: 0, rowWidth: 0 })), + ).toEqual({}); + }); + + it("reports nothing when the row is too narrow to share", () => { + expect( + measureStatusSlot( + promptRow({ labelWidth: 20, labelHeight: 1, rowWidth: 30 }), + ), + ).toEqual({}); + }); + + it("reports nothing rather than guessing at an unfamiliar tree", () => { + expect(measureStatusSlot(undefined)).toEqual({}); + expect(measureStatusSlot({})).toEqual({}); + expect(measureStatusSlot({ parent: { width: 80, primaryAxis: "row" } })).toEqual( + {}, + ); + }); + + it("stops walking rather than following a cycle forever", () => { + const looped: FakeNode = { width: 4, getChildren: () => [] }; + looped.parent = looped; + expect(measureStatusSlot(looped)).toEqual({}); + }); +}); + +describe("showsQuotaForSession", () => { + it("always shows the line by default", () => { + expect( + showsQuotaForSession({ audience: "always", providerID: "anthropic" }), + ).toBe(true); + }); + + it("hides the line for a model this plugin does not route", () => { + expect( + showsQuotaForSession({ audience: "codex-models", providerID: "anthropic" }), + ).toBe(false); + expect( + showsQuotaForSession({ audience: "codex-models", providerID: "openai" }), + ).toBe(true); + }); + + it("shows the line when the session has not said what it runs yet", () => { + expect( + showsQuotaForSession({ audience: "codex-models", providerID: undefined }), + ).toBe(true); + }); +}); diff --git a/test/tui-status.test.ts b/test/tui-status.test.ts index 9ced85f5..25146bb3 100644 --- a/test/tui-status.test.ts +++ b/test/tui-status.test.ts @@ -1,14 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + fitStatusLines, formatPromptStatusText, formatQuotaDetailsText, + formatQuotaOverviewStatusLines, + formatQuotaOverviewStatusText, + formatQuotaResetsStatusLines, resolvePromptReasoningVariant, + resolveQuotaOverviewTone, resolveQuotaPromptTone, + wrapStatusCandidate, type CompactQuotaStatus, type PromptStatusConfig, type PromptStatusMessage, } from "../lib/tui-status.js"; +import type { + QuotaOverviewAccount, + QuotaOverviewOptions, +} from "../lib/quota-overview.js"; const sep = ` ${String.fromCharCode(183)} `; const quota: CompactQuotaStatus = { @@ -676,3 +686,316 @@ describe("tui quota status hostile inputs", () => { expect(text).not.toMatch(/NaN|Infinity/); }); }); + +describe("pool-wide prompt status", () => { + const NOW = Date.UTC(2026, 8, 17, 12, 0, 0); + const DAY = 24 * 60 * 60 * 1000; + const pool: QuotaOverviewAccount[] = [ + { + index: 1, + planType: "self_serve_business_prolite", + windows: [{ leftPercent: 87 }], + }, + { + index: 2, + planType: "pro", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 3 * DAY }], + }, + { index: 3, planType: "plus", windows: [{ leftPercent: 88 }] }, + ]; + const options: QuotaOverviewOptions = { + mode: "used", + layout: "accounts", + names: "number", + order: "number", + multipliers: true, + allotment: false, + resetTimes: "low", + resetCredits: true, + recovery: false, + now: NOW, + }; + + it("shows every account when the terminal has room", () => { + expect( + formatQuotaOverviewStatusText({ accounts: pool, options, width: 200 }), + ).toBe("80%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12%"); + }); + + it("keeps the breakdown on a terminal the tiered budget would have given up on", () => { + // 120 columns caps the single-account budget at 64 characters; the pool + // line is 44 and must survive. + expect( + formatQuotaOverviewStatusText({ accounts: pool, options, width: 120 }), + ).toContain("#3"); + }); + + it("gives up the account numbers before the percentages behind them", () => { + // Three figures in account order say more than `3 accounts` does, and + // cost four characters more. + const text = formatQuotaOverviewStatusText({ + accounts: pool, + options, + width: 60, + }); + expect(text).toBe("80%: 13%, 100%, 12%"); + expect(text.length).toBeLessThanOrEqual(24); + }); + + it("degrades to the count when position would name the wrong account", () => { + // One account nobody could read leaves a hole in the line, so the + // unnamed form is not offered and the count is the next rung down. + const text = formatQuotaOverviewStatusText({ + accounts: [...pool, { index: 4, planType: "plus", windows: [] }], + options, + width: 60, + }); + expect(text).toBe("80%: 4 accounts"); + }); + + it("leaves the model label its room on an 80-column terminal", () => { + // Measured against the real TUI: this slot shares its row with the + // model label ("Build - Big Pickle OpenCode Zen", 31 characters) inside + // the prompt border. A 48-character line was ellipsized through its + // middle AND wrapped the label onto a second row, so 80 columns must + // buy no more than 40. + const crowded: QuotaOverviewAccount[] = [ + { index: 1, planType: "pro", windows: [{ leftPercent: 8, resetAtMs: NOW + 6 * DAY }] }, + { index: 2, planType: "self_serve_business_prolite", windows: [{ leftPercent: 0, resetAtMs: NOW + 3 * DAY }] }, + { index: 3, planType: "team", windows: [{ leftPercent: 62 }] }, + { index: 4, planType: "plus", windows: [{ leftPercent: 100 }] }, + { index: 5, planType: "pro", windows: [{ leftPercent: 45 }] }, + ]; + const text = formatQuotaOverviewStatusText({ + accounts: crowded, + options: { ...options, mode: "free" }, + width: 80, + }); + expect(text.length).toBeLessThanOrEqual(40); + expect(text).not.toContain("..."); + }); + + it("abbreviates the count rather than giving up on it", () => { + const text = formatQuotaOverviewStatusText({ + accounts: pool, + options, + width: 20, + }); + expect(text).toBe("80%: 3 acct."); + }); + + it("falls back to the bare total when nothing else fits", () => { + expect( + formatQuotaOverviewStatusText({ accounts: pool, options, width: 4 }), + ).toBe("80%"); + }); + + it("uses the measured space in preference to the whole terminal", () => { + // The same 200-column terminal, once with and once without a measured + // budget. A sidebar and a model label can leave this slot 14 columns on + // a wide terminal, and the measurement is what stops the renderer + // ellipsizing a 44-character line through its middle. + expect( + formatQuotaOverviewStatusText({ accounts: pool, options, width: 200 }), + ).toBe("80%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12%"); + expect( + formatQuotaOverviewStatusText({ + accounts: pool, + options, + width: 200, + availableChars: 14, + }), + ).toBe("80%: 3 acct."); + }); + + it("stays conservative when the width is unknown", () => { + const text = formatQuotaOverviewStatusText({ accounts: pool, options }); + expect(text.length).toBeLessThanOrEqual(32); + }); + + it("renders nothing for a pool with no readable quota", () => { + expect( + formatQuotaOverviewStatusText({ accounts: [], options, width: 200 }), + ).toBe(""); + }); + + it("uses a second row before it gives up any detail", () => { + // 30 columns on one row loses the badges and the reset; across two it + // keeps the whole line. + expect( + formatQuotaOverviewStatusLines({ + accounts: pool, + options, + availableChars: 30, + maxRows: 2, + }), + ).toEqual(["80%: #1 5x 13%,", "#2 20x 100% 3d 1r, #3 1x 12%"]); + }); + + it("keeps a one-row line on one row", () => { + expect( + formatQuotaOverviewStatusLines({ + accounts: pool, + options, + availableChars: 60, + maxRows: 2, + }), + ).toEqual(["80%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12%"]); + }); + + it("renders the reset-credit line only once the pool is spent", () => { + const spent: QuotaOverviewAccount[] = [ + { + index: 1, + planType: "plus", + email: "damian@nowaker.net", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 6 * DAY }], + }, + { + index: 2, + planType: "plus", + email: "work@example.com", + resetCredits: 2, + windows: [{ leftPercent: 0, resetAtMs: NOW + 4 * DAY }], + }, + ]; + expect( + formatQuotaResetsStatusLines({ accounts: spent, options, availableChars: 80 }), + ).toEqual([ + "Free resets: 6d 1r #1, 4d 2r #2", + ]); + expect( + formatQuotaResetsStatusLines({ accounts: pool, options, availableChars: 80 }), + ).toEqual([]); + }); + + it("shortens the reset-credit line rather than overflowing", () => { + const spent: QuotaOverviewAccount[] = [ + { + index: 1, + planType: "plus", + email: "damian@nowaker.net", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 6 * DAY }], + }, + { + index: 3, + planType: "plus", + email: "work@example.com", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 4 * DAY }], + }, + ]; + expect( + formatQuotaResetsStatusLines({ accounts: spent, options, availableChars: 30 }), + ).toEqual(["Resets: 6d 1r #1, 4d 1r #3"]); + expect( + formatQuotaResetsStatusLines({ accounts: spent, options, availableChars: 11 }), + ).toEqual(["Resets: 2"]); + }); + + it("names no account on the resets line when names are switched off", () => { + const spent: QuotaOverviewAccount[] = [ + { + index: 1, + planType: "plus", + email: "damian@nowaker.net", + resetCredits: 1, + windows: [{ leftPercent: 0, resetAtMs: NOW + 6 * DAY }], + }, + { + index: 2, + planType: "plus", + email: "work@example.com", + resetCredits: 2, + windows: [{ leftPercent: 0, resetAtMs: NOW + 4 * DAY }], + }, + ]; + expect( + formatQuotaResetsStatusLines({ + accounts: spent, + options: { ...options, names: "none" }, + availableChars: 80, + }), + ).toEqual(["Free resets: 6d 1r, 4d 2r"]); + }); +}); + +describe("wrapStatusCandidate", () => { + it("breaks only at the separators the line already has", () => { + expect(wrapStatusCandidate("20%: #1 87%, #2 0%, #3 88%", 16, 2)).toEqual([ + "20%: #1 87%,", + "#2 0%, #3 88%", + ]); + }); + + it("leaves a line that already fits alone", () => { + expect(wrapStatusCandidate("20%: 3 accounts", 20, 2)).toEqual([ + "20%: 3 accounts", + ]); + }); + + it("refuses a line that would need more rows than it has", () => { + expect( + wrapStatusCandidate("20%: #1 87%, #2 0%, #3 88%", 12, 2), + ).toBeUndefined(); + expect(wrapStatusCandidate("20%: #1 87%, #2 0%, #3 88%", 12, 3)).toEqual([ + "20%: #1 87%,", + "#2 0%,", + "#3 88%", + ]); + }); + + it("refuses a single segment that cannot fit a row at all", () => { + expect(wrapStatusCandidate("20%: #1 87%, #2 0%", 8, 4)).toBeUndefined(); + }); + + it("never wraps when it is only allowed one row", () => { + expect(wrapStatusCandidate("20%: #1 87%, #2 0%", 12, 1)).toBeUndefined(); + }); +}); + +describe("fitStatusLines", () => { + it("takes the first rung that fits, wrapped or not", () => { + expect(fitStatusLines(["aaaa, bbbb", "cc"], 5, 2)).toEqual(["aaaa,", "bbbb"]); + expect(fitStatusLines(["aaaa, bbbb", "cc"], 5, 1)).toEqual(["cc"]); + }); + + it("falls back to the shortest rung rather than rendering nothing", () => { + expect(fitStatusLines(["aaaaaa", "bbbb"], 2, 1)).toEqual(["bbbb"]); + }); + + it("renders nothing for an empty ladder", () => { + expect(fitStatusLines([], 40, 2)).toEqual([]); + }); +}); + +describe("resolveQuotaOverviewTone", () => { + const account = (leftPercent: number): QuotaOverviewAccount => ({ + index: 1, + planType: "plus", + windows: [{ leftPercent }], + }); + + it("stays normal while one account still has room", () => { + expect(resolveQuotaOverviewTone([account(0), account(80)])).toBe("normal"); + }); + + it("warns only once the whole pool is low", () => { + expect(resolveQuotaOverviewTone([account(0), account(20)])).toBe("warning"); + }); + + it("turns red once nothing in the pool has room", () => { + expect(resolveQuotaOverviewTone([account(0), account(5)])).toBe("danger"); + }); + + it("reports a stale reading as stale whatever the numbers say", () => { + expect(resolveQuotaOverviewTone([account(90)], true)).toBe("stale"); + }); + + it("reports an unreadable pool as unknown", () => { + expect(resolveQuotaOverviewTone([])).toBe("unknown"); + }); +}); diff --git a/tui.ts b/tui.ts index 1d89c666..f5510e18 100644 --- a/tui.ts +++ b/tui.ts @@ -5,8 +5,24 @@ import type { JSX } from "@opentui/solid"; import { getCodexTuiMaskEmail, getCodexTuiMaskEmailInQuotaDetails, + getQuotaDisplay, + getQuotaStatus, loadPluginConfig, + type QuotaStatusAudience, + type QuotaStatusConfig, + type QuotaStatusScreen, } from "./lib/config.js"; +import { PROVIDER_ID } from "./lib/constants.js"; +import type { QuotaDisplayMode } from "./lib/quota-display.js"; +import type { + QuotaOverviewAccount, + QuotaOverviewOptions, +} from "./lib/quota-overview.js"; +import { + fetchTuiQuotaOverview, + mergeOverviewWithLatestAccount, + toQuotaOverviewAccounts, +} from "./lib/tui-quota-overview.js"; import { createUsageAccountFingerprint, ensureCodexUsageAccessToken, @@ -21,13 +37,18 @@ import { import { formatPromptStatusText, formatQuotaDetailsText, + formatQuotaOverviewStatusLines, + formatQuotaResetsStatusLines, + resolveQuotaOverviewTone, resolveQuotaPromptTone, type CompactQuotaLimit, type CompactQuotaStatus, + type QuotaPromptTone, } from "./lib/tui-status.js"; import { createTuiQuotaSnapshot, getTuiQuotaCachePath, + getTuiQuotaOverviewCachePath, isFreshTuiQuotaSnapshot, isTuiQuotaSnapshot, readTuiQuotaSnapshot, @@ -40,6 +61,17 @@ const CACHE_KEY = "oc-codex-multi-auth:tui-status:v2"; const REFRESH_INTERVAL_MS = 5 * 60 * 1000; const EVENT_REFRESH_DEBOUNCE_MS = 750; const ACCOUNT_POLL_INTERVAL_MS = 1_000; +// The status line is the one surface that reads this configuration once and +// then renders it for the rest of the session, so it is the only one where an +// edit would otherwise need a restart to be seen. `loadPluginConfig` already +// re-reads the file on every call and only skips re-parsing when the bytes are +// unchanged, so polling it costs one read of a file measured in hundreds of +// bytes. +const CONFIG_POLL_INTERVAL_MS = 2_000; +// Re-reading three numbers off the laid-out node, which is cheap enough to do +// often and has to be: the first pass happens before any layout exists, and +// until it is picked up the line is budgeting against the whole terminal. +const LAYOUT_MEASURE_INTERVAL_MS = 500; type StoredQuotaStatus = TuiQuotaSnapshot; @@ -51,6 +83,58 @@ type SolidRuntime = Pick< let inFlightRefresh: Promise | undefined; +type QuotaOverviewState = + | { type: "loading" } + | { type: "unavailable" } + | { type: "ready"; accounts: readonly QuotaOverviewAccount[]; stale: boolean }; + +let inFlightOverview: Promise | undefined; + +async function refreshQuotaOverviewInner( + api: TuiPluginApi, +): Promise { + try { + const now = Date.now(); + const snapshot = await fetchTuiQuotaOverview({ + cachePath: getTuiQuotaOverviewCachePath(api.state.path.state), + now, + }); + if (!snapshot || snapshot.accounts.length === 0) { + return { type: "unavailable" }; + } + const merged = mergeOverviewWithLatestAccount( + snapshot, + await readSharedQuotaStatus(api), + ); + return { + type: "ready", + accounts: toQuotaOverviewAccounts(merged), + // Judged on the poll, not on the merged account: one account read a + // moment ago does not make a five-minute-old reading of the other six + // current. + stale: !isFreshTuiQuotaSnapshot(merged, now), + }; + } catch { + return { type: "unavailable" }; + } +} + +/** + * Coalesce concurrent refreshes. + * + * Every session event that can move a quota schedules one of these, and + * without this guard a burst of tool completions would start several passes + * over the whole pool at once. The pass itself is already cheap while the + * cache is fresh - it reads one file - so the events can stay wired to it. + */ +function refreshQuotaOverview(api: TuiPluginApi): Promise { + if (inFlightOverview) return inFlightOverview; + inFlightOverview = refreshQuotaOverviewInner(api).finally(() => { + inFlightOverview = undefined; + }); + return inFlightOverview; +} + function isStoredQuotaStatus(value: unknown): value is StoredQuotaStatus { return isTuiQuotaSnapshot(value); } @@ -303,11 +387,297 @@ export function shouldRefreshQuotaForEvent(event: Event): boolean { } } -function createPromptStatus( +type PromptStatusOptions = { + maskEmail: boolean; + maskEmailInQuotaDetails: boolean; + quotaDisplay: QuotaDisplayMode; + quotaStatus: QuotaStatusConfig; +}; + +export function readPromptStatusOptions(): PromptStatusOptions { + const pluginConfig = loadPluginConfig(); + return { + maskEmail: getCodexTuiMaskEmail(pluginConfig), + maskEmailInQuotaDetails: getCodexTuiMaskEmailInQuotaDetails(pluginConfig), + quotaDisplay: getQuotaDisplay(pluginConfig), + quotaStatus: getQuotaStatus(pluginConfig), + }; +} + +/** + * Whether two readings of the configuration would render the same line. + * + * Compared field by field rather than by identity: every poll builds a fresh + * object, so identity always differs and pushing it into a signal would + * re-render the status line every two seconds forever. + */ +export function samePromptStatusOptions( + left: PromptStatusOptions, + right: PromptStatusOptions, +): boolean { + const leftStatus = left.quotaStatus; + const rightStatus = right.quotaStatus; + return ( + left.maskEmail === right.maskEmail && + left.maskEmailInQuotaDetails === right.maskEmailInQuotaDetails && + left.quotaDisplay === right.quotaDisplay && + leftStatus.screens.length === rightStatus.screens.length && + leftStatus.screens.every( + (screen, position) => screen === rightStatus.screens[position], + ) && + leftStatus.rotateMs === rightStatus.rotateMs && + leftStatus.layout === rightStatus.layout && + leftStatus.accountNames === rightStatus.accountNames && + leftStatus.order === rightStatus.order && + leftStatus.multipliers === rightStatus.multipliers && + leftStatus.allotment === rightStatus.allotment && + leftStatus.resetTimes === rightStatus.resetTimes && + leftStatus.resetCredits === rightStatus.resetCredits && + leftStatus.recovery === rightStatus.recovery && + leftStatus.rows === rightStatus.rows && + leftStatus.showFor === rightStatus.showFor + ); +} + +/** + * What the renderer actually gave this slot, once it has laid the prompt out. + * + * The terminal's own width is the wrong budget for a line sharing its row with + * a sidebar, and wrong by an amount nothing in this plugin can derive - so the + * row is measured instead of computed. + */ +type StatusSlotMetrics = { + /** Columns this line may spend, or nothing when the prompt is unreadable. */ + availableChars?: number; +}; + +type LayoutNode = { + width?: unknown; + primaryAxis?: unknown; + parent?: unknown; + getChildren?: unknown; +}; + +/** + * Columns on the prompt's bottom row that belong to the model label beside + * this line, plus the gap between them. + * + * A constant rather than the label's measured width, and that is the whole + * point. The row lays both boxes out by their content, so a label with no room + * left is SHRUNK to whatever this line did not take - measuring it would make + * the budget a function of the line's own length, and every render would + * ratchet it further down. The row's width is the one number on that row that + * this line cannot influence. + * + * 40 is measured against the real TUI: "Build - Big Pickle OpenCode Zen" is 31 + * characters, and at 80 columns a 48-character line was ellipsized through its + * middle AND pushed the label onto a second row. + */ +const PROMPT_LABEL_RESERVED_CHARS = 40; +const MAX_LAYOUT_WALK_DEPTH = 8; + +function asLayoutNode(value: unknown): LayoutNode | undefined { + return typeof value === "object" && value !== null ? value : undefined; +} + +function layoutSize(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? value + : undefined; +} + +function layoutChildren(node: LayoutNode): LayoutNode[] { + if (typeof node.getChildren !== "function") return []; + try { + const children: unknown = node.getChildren(); + if (!Array.isArray(children)) return []; + return children + .map((child) => asLayoutNode(child)) + .filter((child): child is LayoutNode => Boolean(child)); + } catch { + return []; + } +} + +/** + * Measure the row this line sits on, by walking up to it. + * + * The walk looks for the nearest ancestor laid out as a row with something else + * in it - the prompt's bottom row, where the model label is the something else. + * That row stretches to the prompt's inner width, which is the number the + * terminal width fails to be whenever a sidebar is open, and the only number on + * that row this line does not influence. + * + * Every hop is guarded and the whole thing returns nothing rather than a guess: + * before the first layout pass there are no widths to read, and a host that + * rearranges its prompt must degrade to the width heuristic rather than compute + * a budget from numbers that no longer mean what they did. + */ +export function measureStatusSlot(node: unknown): StatusSlotMetrics { + const chain: LayoutNode[] = []; + let current = asLayoutNode(node); + for (let depth = 0; current && depth < MAX_LAYOUT_WALK_DEPTH; depth += 1) { + chain.push(current); + current = asLayoutNode(current.parent); + } + for (const [position, ancestor] of chain.entries()) { + if (position === 0) continue; + if (ancestor.primaryAxis !== "row") continue; + const rowWidth = layoutSize(ancestor.width); + if (!rowWidth) continue; + const children = layoutChildren(ancestor); + if (children.length < 2) continue; + const mine = chain[position - 1]; + if (!mine || !children.includes(mine)) continue; + const availableChars = rowWidth - PROMPT_LABEL_RESERVED_CHARS; + if (availableChars < 1) continue; + return { availableChars }; + } + return {}; +} + +/** + * One status line's data pipeline, separated from the node that shows it. + * + * The screens read different things - one polls the pool, one tracks whichever + * account is serving - and which screens are wanted can change while a session + * is open. Keeping the data behind this interface lets the node stay put and + * read from whichever pipelines are current, rather than the slot having to + * replace a node the renderer already mounted. + */ +type QuotaStatusController = { + screens: readonly QuotaStatusScreen[]; + lines( + screen: QuotaStatusScreen, + options: PromptStatusOptions, + layout: { availableChars?: number; maxRows: number }, + ): string[]; + tone(screen: QuotaStatusScreen): QuotaPromptTone; + dispose(): void; +}; + +function toQuotaOverviewOptions( + options: PromptStatusOptions, + now: number, +): QuotaOverviewOptions { + // Spelled out rather than spread: `QuotaStatusConfig` calls the free/used + // wording `quotaDisplay` and its own screen list `screens`, while + // `QuotaOverviewOptions.mode` IS the wording - spreading one over the other + // silently renders every percentage as headroom. + return { + mode: options.quotaDisplay, + layout: options.quotaStatus.layout, + names: options.quotaStatus.accountNames, + order: options.quotaStatus.order, + multipliers: options.quotaStatus.multipliers, + allotment: options.quotaStatus.allotment, + resetTimes: options.quotaStatus.resetTimes, + resetCredits: options.quotaStatus.resetCredits, + recovery: options.quotaStatus.recovery, + maskEmail: options.maskEmail, + now, + }; +} + +/** + * The pool-wide status line's data pipeline. + * + * Kept apart from the active-account one rather than branching inside it: + * that one maintains a serving-account fingerprint, a snapshot revision and a + * one-second identity poll, all of which exist to answer "which account is + * this" - the question this mode is built to stop asking. + */ +function createOverviewQuotaController( api: TuiPluginApi, solid: SolidRuntime, - options: { maskEmail: boolean; maskEmailInQuotaDetails: boolean }, -): JSX.Element { +): QuotaStatusController { + const [state, setState] = solid.createSignal({ + type: "loading", + }); + const refresh = (): void => { + void refreshQuotaOverview(api).then(setState, () => { + setState({ type: "unavailable" }); + }); + }; + let refreshTimeout: ReturnType | undefined; + const scheduleRefresh = (): void => { + if (refreshTimeout) clearTimeout(refreshTimeout); + refreshTimeout = setTimeout(() => { + refreshTimeout = undefined; + refresh(); + }, EVENT_REFRESH_DEBOUNCE_MS); + }; + + refresh(); + const interval = setInterval(refresh, REFRESH_INTERVAL_MS); + const disposers = [ + api.event.on("message.updated", (event) => { + if (shouldRefreshQuotaForEvent(event)) scheduleRefresh(); + }), + api.event.on("message.part.updated", (event) => { + if (shouldRefreshQuotaForEvent(event)) scheduleRefresh(); + }), + api.event.on("session.idle", (event) => { + if (shouldRefreshQuotaForEvent(event)) scheduleRefresh(); + }), + api.event.on("session.status", (event) => { + if (shouldRefreshQuotaForEvent(event)) scheduleRefresh(); + }), + api.event.on("session.error", (event) => { + if (shouldRefreshQuotaForEvent(event)) scheduleRefresh(); + }), + ]; + return { + screens: ["overview", "resets"], + lines(screen, options, layout) { + const current = state(); + if (current.type !== "ready") { + // Blank while the first pass runs; a placeholder swapped out a + // moment later is exactly the flicker this mode removes. The + // reset screen stays blank either way - it has nothing to add + // to a pool nobody has read yet. + if (screen === "resets") return []; + return current.type === "loading" ? [] : ["limits ?"]; + } + const render = + screen === "resets" + ? formatQuotaResetsStatusLines + : formatQuotaOverviewStatusLines; + return render({ + accounts: current.accounts, + options: toQuotaOverviewOptions(options, Date.now()), + width: api.renderer.width, + availableChars: layout.availableChars, + maxRows: layout.maxRows, + }); + }, + tone(screen) { + const current = state(); + if (current.type === "ready") { + // The reset screen only appears once nothing has headroom left, + // so the pool tone it would otherwise carry is always danger and + // says nothing. Warning is the honest colour for "here is the + // thing you can still do about it". + if (screen === "resets") { + return current.stale ? "stale" : "warning"; + } + return resolveQuotaOverviewTone(current.accounts, current.stale); + } + return current.type === "loading" ? "unknown" : "warning"; + }, + dispose() { + clearInterval(interval); + if (refreshTimeout) clearTimeout(refreshTimeout); + for (const dispose of disposers) dispose(); + }, + }; +} + +/** The serving-account pipeline: the status line's original behaviour. */ +function createActiveQuotaController( + api: TuiPluginApi, + solid: SolidRuntime, +): QuotaStatusController { const [quota, setQuota] = solid.createSignal({ type: "loading", }); @@ -392,31 +762,239 @@ function createPromptStatus( const disposeSessionError = api.event.on("session.error", (event) => { if (shouldRefreshQuotaForEvent(event)) scheduleRefresh(); }); + return { + screens: ["active"], + lines(_screen, options) { + const text = formatPromptStatusText({ + quota: quota(), + width: api.renderer.width, + maskEmail: options.maskEmail, + quotaDisplay: options.quotaDisplay, + }); + return text ? [text] : []; + }, + tone() { + return resolveQuotaPromptTone(quota()); + }, + dispose() { + clearInterval(interval); + clearInterval(accountInterval); + if (refreshTimeout) clearTimeout(refreshTimeout); + disposeMessageUpdated(); + disposeMessagePartUpdated(); + disposeSessionIdle(); + disposeSessionStatus(); + disposeSessionError(); + }, + }; +} + +/** + * The status-line node, which outlives any change to how it is configured. + * + * Both the shape of the line and the pipeline behind it come from a file the + * user edits while sessions are open, so this polls that file and swaps the + * pipeline underneath a node the renderer keeps mounted. The alternative - + * re-registering the slot - would ask the renderer to replace a live node, and + * a mode change is exactly when it must not blink. + */ +/** + * The pipelines a set of screens needs, and which screen each one answers. + * + * `resets` is served by the pool pipeline rather than one of its own: it needs + * every account's windows and banked credits, which is exactly what that + * pipeline already gathers, and a second poller reading the same endpoint for + * the same numbers would double the request cost to say the same thing. + */ +function createQuotaControllers( + api: TuiPluginApi, + solid: SolidRuntime, + screens: readonly QuotaStatusScreen[], +): QuotaStatusController[] { + const controllers: QuotaStatusController[] = []; + if (screens.includes("active")) { + controllers.push(createActiveQuotaController(api, solid)); + } + if (screens.includes("overview") || screens.includes("resets")) { + controllers.push(createOverviewQuotaController(api, solid)); + } + return controllers; +} + +/** + * Whether the account pool is worth naming for the model in front of the user. + * + * Unknown counts as yes. A line the user asked for should not disappear + * because a fresh session has no message to read a provider off yet, and being + * told about a pool one model does not draw from is a smaller wrong than being + * told nothing while it runs out. + */ +export function showsQuotaForSession(params: { + audience: QuotaStatusAudience; + providerID: string | undefined; +}): boolean { + if (params.audience === "always") return true; + return !params.providerID || params.providerID === PROVIDER_ID; +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : undefined; +} + +/** + * The provider actually serving this session, newest statement first. + * + * A message records what ran, which is the only reliable answer once a session + * has moved off the configured default; the configured model is the fallback + * for a session that has not run anything yet. + */ +function resolveSessionProviderId( + api: TuiPluginApi, + sessionID: string | undefined, +): string | undefined { + if (sessionID) { + try { + const messages = api.state.session.messages(sessionID); + for (let position = messages.length - 1; position >= 0; position -= 1) { + const message = messages[position]; + if (message?.role !== "assistant") continue; + const providerID = getString(message.providerID); + if (providerID) return providerID; + } + } catch { + // The session may not be in the store yet; fall through to config. + } + } + const configured = getString(api.state.config.model); + if (!configured) return undefined; + const slashIndex = configured.indexOf("/"); + return slashIndex > 0 ? configured.slice(0, slashIndex) : undefined; +} + +/** + * The status-line node, which outlives any change to how it is configured. + * + * Both the shape of the line and the pipelines behind it come from a file the + * user edits while sessions are open, so this polls that file and swaps the + * pipelines underneath a node the renderer keeps mounted. The alternative - + * re-registering the slot - would ask the renderer to replace a live node, and + * a screen change is exactly when it must not blink. + */ +function createPromptStatus( + api: TuiPluginApi, + solid: SolidRuntime, + initialOptions: PromptStatusOptions, + sessionID: string | undefined, +): JSX.Element { + const [options, setOptions] = solid.createSignal(initialOptions); + const [metrics, setMetrics] = solid.createSignal({}); + const [rotation, setRotation] = solid.createSignal(0); + let controllers = createQuotaControllers( + api, + solid, + initialOptions.quotaStatus.screens, + ); + let controllerScreens = initialOptions.quotaStatus.screens; + let rotationInterval: ReturnType | undefined; + + const node = solid.createElement("text"); + + const restartRotation = (screens: readonly QuotaStatusScreen[]): void => { + if (rotationInterval) clearInterval(rotationInterval); + rotationInterval = undefined; + if (screens.length < 2) return; + rotationInterval = setInterval(() => { + setRotation((tick) => tick + 1); + }, options().quotaStatus.rotateMs); + }; + restartRotation(controllerScreens); + + const remeasure = (): void => { + const next = measureStatusSlot(node); + if (next.availableChars === metrics().availableChars) return; + setMetrics(next); + }; + const measureInterval = setInterval(remeasure, LAYOUT_MEASURE_INTERVAL_MS); + + const configInterval = setInterval(() => { + const next = readPromptStatusOptions(); + if (samePromptStatusOptions(options(), next)) return; + const nextScreens = next.quotaStatus.screens; + const screensChanged = + nextScreens.length !== controllerScreens.length || + nextScreens.some((screen, position) => screen !== controllerScreens[position]); + // The swap happens BEFORE the signal that re-renders against it. The + // other order leaves the line blank until something else happens to + // change: the render triggered by `setOptions` would read the outgoing + // pipelines, find none of them serving the incoming screens, subscribe + // to nothing, and never hear the new pipeline resolve. + if (screensChanged) { + for (const controller of controllers) controller.dispose(); + controllers = createQuotaControllers(api, solid, nextScreens); + controllerScreens = nextScreens; + setRotation(0); + } + setOptions(next); + restartRotation(nextScreens); + }, CONFIG_POLL_INTERVAL_MS); + solid.onCleanup(() => { - clearInterval(interval); - clearInterval(accountInterval); - if (refreshTimeout) clearTimeout(refreshTimeout); - disposeMessageUpdated(); - disposeMessagePartUpdated(); - disposeSessionIdle(); - disposeSessionStatus(); - disposeSessionError(); + clearInterval(configInterval); + clearInterval(measureInterval); + if (rotationInterval) clearInterval(rotationInterval); + for (const controller of controllers) controller.dispose(); }); - const node = solid.createElement("text"); + /** + * The screen on show right now, picked from the ones that have something to + * say. A screen with nothing to render is skipped rather than shown blank, + * which is what lets `resets` sit in the rotation permanently and only + * appear on the day it matters. + */ + const current = (): { screen: QuotaStatusScreen; lines: string[] } | undefined => { + const currentOptions = options(); + if ( + !showsQuotaForSession({ + audience: currentOptions.quotaStatus.showFor, + providerID: resolveSessionProviderId(api, sessionID), + }) + ) { + return undefined; + } + const layout = { + availableChars: metrics().availableChars, + maxRows: currentOptions.quotaStatus.rows, + }; + const rendered: Array<{ screen: QuotaStatusScreen; lines: string[] }> = []; + for (const screen of currentOptions.quotaStatus.screens) { + const controller = controllers.find((candidate) => + candidate.screens.includes(screen), + ); + if (!controller) continue; + const lines = controller.lines(screen, currentOptions, layout); + if (lines.length > 0) rendered.push({ screen, lines }); + } + if (rendered.length === 0) return undefined; + return rendered[Math.abs(rotation()) % rendered.length]; + }; + solid.spread( node, { get content() { - return formatPromptStatusText({ - quota: quota(), - width: api.renderer.width, - maskEmail: options.maskEmail, - }); + // A newline is how one text node becomes two rows: the renderer + // measures the content it was given, so the node grows to match + // instead of the slot having to add and remove child nodes. + return current()?.lines.join("\n") ?? ""; }, get fg() { - const current = quota(); - const tone = resolveQuotaPromptTone(current); + const screen = current()?.screen; + const controller = screen + ? controllers.find((candidate) => candidate.screens.includes(screen)) + : undefined; + const tone = screen && controller ? controller.tone(screen) : "unknown"; if (tone === "danger") return api.theme.current.error; if (tone === "warning" || tone === "stale") { return api.theme.current.warning; @@ -424,6 +1002,10 @@ function createPromptStatus( if (tone === "normal") return api.theme.current.success; return api.theme.current.textMuted; }, + // The host centres this slot against a model label that wraps to two + // rows on a narrow terminal, which put a one-row line on the bottom + // row and left the top one empty. Reading starts at the top. + alignSelf: "flex-start", selectable: false, truncate: true, wrapMode: "none", @@ -433,10 +1015,11 @@ function createPromptStatus( return node; } -function showQuotaDetails( - api: TuiPluginApi, - options: { maskEmail: boolean; maskEmailInQuotaDetails: boolean }, -): void { +function showQuotaDetails(api: TuiPluginApi): void { + // Read at open time, not at startup: the dialog is one keystroke away from + // the line it explains, and the two disagreeing about `used` vs `free` + // after an edit would be worse than either being stale alone. + const options = readPromptStatusOptions(); void refreshQuotaStatus(api).then( (status) => { api.ui.dialog.replace(() => @@ -444,6 +1027,7 @@ function showQuotaDetails( title: "Codex quota", message: formatQuotaDetailsText(status, Date.now(), { maskEmail: options.maskEmail && options.maskEmailInQuotaDetails, + quotaDisplay: options.quotaDisplay, }), onConfirm: () => api.ui.dialog.clear(), }), @@ -464,11 +1048,7 @@ function showQuotaDetails( const module: TuiPluginModule = { id: "oc-codex-multi-auth.status", async tui(api) { - const pluginConfig = loadPluginConfig(); - const promptOptions = { - maskEmail: getCodexTuiMaskEmail(pluginConfig), - maskEmailInQuotaDetails: getCodexTuiMaskEmailInQuotaDetails(pluginConfig), - }; + const promptOptions = readPromptStatusOptions(); const [{ createElement, spread }, { createSignal, onCleanup }] = await Promise.all([import("@opentui/solid"), import("solid-js")]); const solid: SolidRuntime = { @@ -480,7 +1060,8 @@ const module: TuiPluginModule = { api.slots.register({ slots: { - session_prompt_right: () => createPromptStatus(api, solid, promptOptions), + session_prompt_right: (_ctx, props) => + createPromptStatus(api, solid, promptOptions, props.session_id), }, }); const disposeCommand = api.command.register(() => [ @@ -490,7 +1071,7 @@ const module: TuiPluginModule = { description: "Show active account usage, reset times, source, and last refresh.", category: "Codex", - onSelect: () => showQuotaDetails(api, promptOptions), + onSelect: () => showQuotaDetails(api), }, ]); api.lifecycle.onDispose(disposeCommand);