Conversation
Every surface that prints a quota percentage reported the headroom still
left, because that is how Codex itself reports a quota. People who track
consumption rather than headroom had no way to see the other number, and
inverting it by hand on every glance is exactly the arithmetic a display
setting should absorb.
`quotaDisplay` (`free` by default, `used` opt-in) now words the percentage
on every human-readable quota surface: the TUI prompt status line and quota
details dialog, `codex-limits`, the standalone `limits` CLI, the interactive
account check in the auth menu, and the macOS quota notification. One leaf
module, `lib/quota-display.ts`, owns the wording so the status line and the
usage surfaces can both depend on it without depending on each other.
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.
This is presentation only, and deliberately so:
- Exhaustion, rotation blocks, notification thresholds, and the status
line's warning/danger colouring stay keyed on the remaining percentage,
so a nearly spent account still colours red while reading `95%`.
- `usedPercent` and `leftPercent` in JSON output are untouched. Only the
neighbouring `summary` string follows the mode, because it is the
rendered line - it is what the standalone CLI prints - and the numbers
a script wants are already beside it.
Surfaces that name the number swap the noun (`88% left` / `12% used`).
Surfaces that never named it stay bare: the compact status line and the
notification are budgeted around short tokens, and inflating every window
by five characters would push the account hint and reset time off mid-width
terminals for everyone who opts in. The details dialog is one keystroke
away and always spells it out.
The standalone CLI tests now pin `CODEX_AUTH_QUOTA_DISPLAY`. They load the
real `dist/lib/config.js`, whose config path is the developer's own
`~/.opencode` rather than the temp home handed to `runInstaller`, so
without the pin a machine that had opted into `used` would fail every
`% left` assertion in that file.
AI-Tool: opencode
AI-Model: openai/gpt-6-astra
AI-Platform: linux
AI-Harness: Vibeterm 12c112e-dirty
Pin free mode for notification fixtures and exercise weekly threshold delivery in both display modes without changing remaining-quota thresholds. AI-Tool: opencode AI-Model: openai/gpt-6-astra AI-Platform: linux AI-Harness: Vibeterm 12c112e-dirty
A pool of ChatGPT accounts does not hold one kind of capacity. A Pro seat costs twenty times a Plus seat and is marketed as 20x; a Business Premium seat is 5x a Business Standard one. Nothing in the plugin knew that, so any surface summarising more than one account had to treat "50% left" on a Pro seat and "50% left" on a Business Standard seat as the same quantity, which describes a pool nobody has. `describePlanAllotment` maps the `plan_type` that the usage endpoint and the access token both report to that plan's ratio against a 1x seat, its marketing badge (`5x`, `20x`), and its per-seat monthly price. A plan that states no ratio - Free, Go, Enterprise, and a bare `business` that names a workspace rather than a seat - returns an empty allotment rather than a guess, so a caller can tell "1x" apart from "we do not know". `getPlanWeight` falls back to the 1x baseline for those: under-weighting one unplaceable account understates one account, while over-weighting it would let a plan we failed to recognise dominate the number a whole pool is judged by. Two slugs cannot be read off their text and are matched explicitly. `team` is what OpenAI still emits for what it 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. The same tier also arrives spelled several ways (`team`, `chatgptteamplan`, `ChatGPT_Team_Plan`), so case is folded before anything is stripped - otherwise one seat weighs differently depending on which surface named it. The module is a leaf with no imports. Naming a plan already has an owner in lib/auth/plan-tier.ts, and importing that here would drag JWT decoding into the TUI render path for a number that needs none of it. Ratios and prices are OpenAI's published per-seat figures, recorded in docs/plan-allotments.md so the next person revising them knows what they were derived from. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 5611cee
The prompt status line names whichever account served the most recent
request. On one account that is exactly right. On a pool of seven it
changes identity as rotation moves, so the corner of the screen
flickers between accounts and the only way to learn where the pool
stands is to keep watching until every account has gone past.
`quotaStatus.mode: "overview"` renders the pool instead: every account
at once, in a fixed order, so the line changes only when a quota does.
24%: ndycode#1 5x 13%, ndycode#2 20x 100% 3d 1r, ndycode#3 1x 12%
The leading figure is the pool total, and it is a weighted mean rather
than a plain one - lib/plan-allotment.ts supplies the per-plan ratio,
because averaging a Pro seat and a Business Standard seat as equals
describes a pool nobody has. Each account is summarised by its
governing window: the one with the least headroom, since that is what
stops a request, and on a tie the one that blocks for longer. A reset
time is printed only for an account at or below 25% headroom, which is
the threshold the single-account line already uses for the same
question.
Everything beyond the total is a switch, so the line can be as bare as
`24%: 3 accounts` or carry allotment badges, reset times, banked reset
counts and the pool's next recovery (`+12% in 3d`). Defaults are the
per-account breakdown and reset times; badges, banked counts and
recovery are opt-in. `active` stays the default mode, because it is
what every existing install already has and because on one account the
two modes say the same thing at different lengths.
Percentages follow `quotaDisplay` like every other surface, so the same
pool reads `24%` as headroom or `76%` as consumption, and the recovery
clause flips sign with it because it annotates a number that moves the
other way under `used`. Only the wording changes: which window governs
an account, which account is closest to recovering, and the line's
warning/danger colouring all stay keyed on the percentage remaining.
The data costs no extra requests. The quota guard already polls every
enabled account on a bounded interval and throws the per-account detail
away; lib/tui-quota-overview.ts gathers the same read into a pool
snapshot cached beside the existing one. It is a separate file
deliberately: the request path pushes one account per response, and
folding the two together would make every response rewrite a document
describing accounts that request never touched. That live single-account
push is merged back over the polled snapshot when it is newer, so the
one account whose figure a reader can check against their own activity
is not the one that looks stale.
The line degrades by preference rather than by length, dropping the
recovery clause, then the annotations, then the breakdown, leaving the
pool total last. Sorting by length instead would let a form win or lose
by two characters as a percentage crossed from `9%` to `10%`, and the
line would change shape while the reader watched - the flicker this
mode exists to remove.
Its width budget reserves room for the model label sharing the row.
Measured in a real TUI 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 label onto a second row.
AI-Tool: opencode
AI-Model: anthropic/claude-opus-5
AI-Platform: linux
AI-Harness: Vibeterm 5611cee
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The status line read its configuration once, when the TUI plugin loaded, and then rendered that reading for the rest of the session. Every other surface re-reads `openai-codex-auth-config.json` on each call, because `loadPluginConfig` already re-reads the file and only skips re-parsing when the bytes are unchanged. The status line was the one place that froze it, so changing how the line looks meant quitting OpenCode - a restart to move a badge, with every open session lost to it. It now polls that same loader every two seconds and re-renders when the reading differs. Cost is one read of a file measured in hundreds of bytes; when nothing changed, the loader hands back the object it already parsed. `mode` is the hard case, because the two modes are not two renderings of one thing: the pool line polls a pool-wide snapshot, while the serving-account line maintains a fingerprint, a snapshot revision and a one-second identity poll. Each is now a `QuotaStatusController` - a data pipeline behind a small interface - and the node the renderer mounted stays put while the pipeline underneath it is swapped and the outgoing one disposed. Without that, a mode change would have to replace a mounted node, which the slot cannot do. The comparison is field by field rather than by identity, since every poll builds a fresh object and comparing references would re-render the line every two seconds forever. Two readings deliberately stay together: the quota details dialog now reads the configuration when it opens rather than at startup. It is one keystroke from the line it explains, and the two disagreeing about `used` against `free` after an edit would read worse than either being stale alone. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 4e488d6
The pool-wide status line said one thing one way. This makes the whole
shape of it configurable, in the config file and nowhere else: a display
preference belongs to a person, not to whichever shell started OpenCode,
so the `CODEX_AUTH_QUOTA_STATUS*` overrides are gone and every field
below is read from `~/.opencode/openai-codex-auth-config.json` alone.
`layout` replaces the `accounts` boolean and adds a third arrangement.
`aggregate` prints a shared percentage once and keeps only what differs
after it, which is most of the line back on a pool where several seats
are spent:
72%: 12% 3d, 50% 4d, 100% 3d 1r 4d 5d
A group states its own size - `100% x3 4d 5d` - only when its
annotations would not already reveal it, so a group of three where one
member has no reset to print cannot read as a group of two.
`accountNames` chooses between `ndycode#1`, the name the account's owner gave
it, and nothing at all. `order` arranges the accounts by number, by
consumption in either direction, or by which renews first or last, and
every comparison falls back to the account number so two seats reading
the same percentage never trade places between renders.
`allotment` states what the weighted pool adds up to (`66% of 65x`),
over exactly the accounts the mean is taken over so the two figures
cannot disagree. `resetTimes` grows from a boolean into
`never`/`low`/`always`, because 90% spent with an hour to go and 90%
spent with six days to go are not the same situation.
`mode` accepts a list and the line then alternates between those
screens every `rotateMs`. A screen with nothing to say is skipped rather
than shown blank, which is what makes the third screen worth leaving in
the list permanently: `resets` renders only once every account is spent,
and lists the banked credits worth redeeming, latest reset first -
redeeming one on an account that renews by itself tomorrow throws it
away.
Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com
Narrow terminals lose less. `3 accounts` steps through `3 acct.` to `3`
before the line gives up on the count, the recovery clause drops the
word `in` before dropping the clause, and an unnamed breakdown is
offered as the last rung above the count - but only while position still
identifies an account, which means number order with nothing missing
from the line.
Two things about the slot itself, both found by watching the real TUI
rather than by reading it. The budget now comes from the prompt row's
measured width rather than the terminal's, because an open sidebar takes
a share nothing here can derive - at 165 columns a 95-character line was
being ellipsized through its middle, destroying account numbers either
side of the cut. And the line is top-aligned and may occupy up to `rows`
of them, where the host had been centring a one-row line against a model
label that wraps to two, leaving the top row empty.
The label's own width and height are deliberately not measured, and both
were tried and reverted here. The row sizes both boxes by their content
with `alignItems: stretch`, so the label reports this line's 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. `rows` is therefore a plain ceiling, which costs nothing until
the content needs the room.
`showFor` can hide the line unless the session is running a model this
plugin routes. It defaults to showing it always, which is what every
install already does.
An earlier build of this feature spelled two of these fields
differently, and `PluginConfigSchema` validates the file as one unit, so
`accounts: false` and `resetTimes: true` are still accepted and still
mean what they meant. One stale value must not reset every other plugin
setting in the file.
AI-Tool: opencode
AI-Model: anthropic/claude-opus-5
AI-Platform: linux
AI-Harness: Vibeterm 212720d
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DRAFT - please do not review or merge yet
Summary
What changed?
A set of related changes to how the plugin shows a quota to a person.
1. The prompt status line can describe the whole account pool, not just the account serving the last request.
Today the status line names whichever account answered most recently. On one
account that is exactly right. On a pool of several it changes identity as
rotation moves, so the corner of the screen flickers between accounts, and the
only way to learn where the pool actually stands is to keep watching until every
account has gone past.
The new
quotaStatus.mode: "overview"renders the pool instead - every accountat once, in a fixed order, so the line changes only when a quota does:
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 averaging them as equals describes a pool nobody has. Each
account is summarised by its governing window - the one with the least headroom,
since that is what stops a request. A reset time (
3d) is printed only for anaccount at or below 25% headroom, which is the threshold the existing
single-account line already uses for the same question.
1ris a bankedrate-limit reset the account could redeem now.
Everything past the total is a switch, so the same pool can read as bare as
24%: 3 accountsor carry allotment badges, reset times, banked counts and thepool's next recovery (
+12% in 3d); how the accounts are arranged, ordered andnamed is configurable too, which is section 4.
activeremains the defaultmode: it is what every existing install has today, and on a single account the
two modes say the same thing at different lengths.
The line degrades by preference rather than by length on a narrow terminal -
recovery clause first, then annotations, then the per-account breakdown, leaving
the pool total last. Sorting candidates by length instead would let the shape
change as a percentage crossed from
9%to10%, which is the flicker thismode exists to remove.
No extra network traffic. The quota guard already polls every enabled
account on a bounded interval and discards the per-account detail; the new
lib/tui-quota-overview.tscaches that same read as a pool snapshot. Therequest path's live per-response push for the serving account is merged back
over it when newer, so the one account a reader can check against their own
activity is not the one that looks stale.
2. Quota percentages can be worded as consumption instead of headroom.
Every quota percentage is currently the headroom still left, matching how Codex
itself reports a quota.
quotaDisplay: "used"inverts the wording for peoplewho track consumption:
It covers the TUI status line and quota details dialog,
codex-limits, thestandalone
limitsCLI, the interactive account check, and the macOS quotanotification. The used figure is derived from the free one rather than from the
raw
used_percent, so two readings of one window always total 100 - roundingthem independently would let the same window render as
88% lefton one surfaceand
13% usedon another in one session.3. The status line follows a config edit without a restart.
Both settings above are read by a surface that used to read them once, when
the TUI plugin loaded, and then render that reading for the whole session -
so trying either one meant quitting OpenCode and losing every open session to
move a badge. Every other surface already re-reads the file per call, because
loadPluginConfigre-reads it and only skips re-parsing when the bytes areunchanged.
The status line now polls that same loader every two seconds, so an edit shows
up in the running TUI.
modeis the hard case: the two modes are not tworenderings of one thing, but two pipelines polling different sources. Each is
now a controller behind a small interface, and the node the renderer mounted
stays put while the pipeline underneath it is swapped and the outgoing one
disposed. Readings are compared field by field, since every poll builds a
fresh object and comparing references would re-render the line forever.
Why is this needed?
Anyone running more than one ChatGPT account currently has no way to see the
pool. The status line answers "how much has the account that just replied got
left", which is not the question a person with seven accounts is asking - they
want to know whether the pool as a whole can carry the next hour of work, and
which account is about to come back. Watching a flickering line long enough to
reconstruct that by hand is the workaround this removes.
The weighting matters for the same reason. A plain average over mixed plans
reports a number that no account holds and that moves in the wrong direction
when a large seat is spent.
lib/plan-allotment.tsrecords OpenAI's publishedper-seat ratios (Pro 20x, Business Premium / legacy Pro 5x, Plus and Business
Standard 1x) so the total means something;
docs/plan-allotments.mddocumentswhere those figures come from. A plan that states no ratio - Free, Go,
Enterprise, and a bare
businessthat names a workspace rather than a seat -returns "unknown" rather than a guess, so a caller can tell
1xapart from "wedo not know".
Both changes are presentation only and both default to today's behaviour.
Exhaustion, rotation blocks, notification thresholds and the status line's
warning/danger colouring all stay keyed on the percentage remaining, and the
usedPercent/leftPercentfields in JSON output are untouched.4. The shape of the pool line is configurable, and only from the config file.
One arrangement does not suit every pool, so the line is now described rather
than fixed.
layoutchooses between one segment per account, one per distinctpercentage, or a bare count.
accountNameschooses between#1, the name theaccount's owner gave it, and nothing.
orderarranges by number, by consumptionin either direction, or by which renews first or last.
allotmentstates whatthe weighted pool adds up to.
resetTimesgrows from a boolean intonever/low/always.aggregateis the one worth showing. On a pool where several seats are spent,100% 3d, 100% 4d, 100% 5dspends two thirds of its characters repeating anumber that does not change:
A group states its own size -
100% x3 4d 5d- only when its annotations wouldnot already reveal it, so a group of three where one member has no reset to
print cannot read as a group of two.
modeaccepts a list, and the line then alternates between those screens everyrotateMs. A screen with nothing to say is skipped rather than shown blank,which is what makes the third screen worth leaving in the list permanently:
resetsrenders only once every account is spent, and lists the bankedcredits worth redeeming, latest reset first - redeeming one on an account that
renews by itself tomorrow throws it away, while the account six days out is the
one worth spending it on.
Narrow terminals lose less than they did.
3 accountssteps through3 acct.to
3before the line gives up on the count, the recovery clause drops the wordinbefore dropping the clause, and an unnamed breakdown is offered as the lastrung above the count - but only while position still identifies an account,
which means number order with nothing missing from the line.
None of
quotaStatusis overridable by environment variable, and theCODEX_AUTH_QUOTA_STATUS*overrides this PR previously added are removed. It isa display preference belonging to a person rather than to whichever shell
started OpenCode, and
modelAccountPoolssets the file-only precedent.5. The line is laid out against the space it actually has.
Two defects, both found by watching a real TUI rather than by reading the code.
The width budget came from the terminal, which is the wrong number whenever a
sidebar is open: at 165 columns the slot had 73 and the line was built for 99,
so the renderer ellipsized it through the middle and destroyed account numbers
either side of the cut. The budget now comes from the prompt row's measured
width.
The label's own width and height are deliberately not measured, and both
were tried and reverted here. The row sizes both boxes by their content with
alignItems: stretch, so the label reports this line's height once this linegrows, 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.
rowsistherefore a plain ceiling (1-4, default 1) rather than a measurement, and it
costs nothing until the content needs the room, since a candidate that fits on
one row still returns one row.
And 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. The line is now top-aligned, and a second row is one
textnode with anewline in it so the renderer measures it and the node sizes itself.
Config surface (all optional, all defaulting to current behaviour):
quotaDisplayfreeCODEX_AUTH_QUOTA_DISPLAYquotaStatus.modeactivequotaStatus.rotateMs5000quotaStatus.layoutaccountsquotaStatus.accountNamesnumberquotaStatus.ordernumberquotaStatus.multipliersfalsequotaStatus.allotmentfalsequotaStatus.resetTimeslowquotaStatus.resetCreditsfalsequotaStatus.recoveryfalsequotaStatus.rows1quotaStatus.showForalwaysTesting
npm run lintnpm run buildnpm testnpm run lintandnpm run typecheckexit 0.npm teston this branch:3737 passed, 1 skipped, 2 failed - both failures are in
test/index-retry.test.tsand are pre-existing, not caused by this change:they reproduce on unmodified
mainunder full-suite load and pass when thatfile is run alone. Around 230 new tests were added across
test/plan-allotment.test.ts,test/quota-overview.test.ts,test/tui-quota-overview.test.ts,test/tui-status.test.ts,test/tui-status-slot.test.tsandtest/tui-config-reload.test.ts.Beyond the suites, every flag was driven through a real OpenCode TUI built from
dist/, in an isolated HOME with a seeded pool cache, by editing the config fileunderneath a running session - no restart at any point. That is also where four
defects turned up that no unit test would have caught:
budget was the terminal's rather than the prompt row's.
empty.
render triggered by the new configuration read the outgoing pipelines, found
none of them serving the incoming screens, and subscribed to nothing. The swap
now happens before the signal that re-renders against it.
rows: "auto"that took its height from the model label beside itlatched at two rows permanently, because
alignItems: stretchmakes that labelreport this line's own height. That is why
rowsis a plain ceiling now.The final pass covers all thirteen fields, both
quotaDisplaywordings,maskEmail, the aggregate and count layouts, all five orders, the rotationbetween screens, the reset-credit screen on a fully spent pool, the two-row and
three-row renders, and the degradation ladder at eight terminal widths from 110
down to 50 columns.
Compliance Confirmation
No new network calls, no new endpoints, and no change to authentication. The
pool snapshot reuses the existing
/wham/usageread the quota guard alreadyperforms.
On identity: by default the line numbers accounts the way
codex-listandcodex-switchalready do, and renders no email. Two of the new settings canprint one, and both honour
maskEmail.accountNames: "label"prefers a labelset with
codex-labeland falls back to the local part of the email(
damian@nowaker.net->damian, orda***when masking is on). Theresetsscreen prints the full address, because the whole point of it is to say which
account to redeem a credit on - it shortens to the label and then to
#1on anarrow terminal, and it appears only when the pool is spent and something is
redeemable. Neither is on by default. The pool cache gained
emailandlabelfields to support this; it is written mode
0600like every other file theplugin writes.
Notes
docs/plan-allotments.mdis the single placeto revise when OpenAI changes per-seat pricing or ratios; the allotment map is
deliberately reusable, so a future change could also surface
5x/20xbadges in
codex-list,codex-statusand generated account labels.This PR is AI generated, but under direct supervision and on request of Nowaker.