FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(quota): pool-wide status line and used/free percentage wording by Nowaker · Pull Request #261 · ndycode/oc-codex-multi-auth · GitHub

feat(quota): pool-wide status line and used/free percentage wording - #261

Draft
Nowaker wants to merge 6 commits into
ndycode:mainfrom
Nowaker:feat/quota-display-mode
Draft

Nowaker wants to merge 6 commits into
ndycode:mainfrom
Nowaker:feat/quota-display-mode

Conversation

Nowaker commented Sep 17, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

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 account
at once, in a fixed order, so the line changes only when a quota does:

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. 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 an
account at or below 25% headroom, which is the threshold the existing
single-account line already uses for the same question. 1r is a banked
rate-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 accounts or carry allotment badges, reset times, banked counts and the
pool's next recovery (+12% in 3d); how the accounts are arranged, ordered and
named is configurable too, which is section 4. active remains the default
mode: 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% to 10%, which is the flicker this
mode 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.ts caches that same read as a pool snapshot. The
request 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 people
who track consumption:

5h limit: 88% left     ->  5h limit: 12% used

It covers the TUI status line and quota details dialog, codex-limits, the
standalone limits CLI, the interactive account check, and the macOS quota
notification. 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 - rounding
them independently would let the same window render as 88% left on one surface
and 13% used on 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
loadPluginConfig re-reads it and only skips re-parsing when the bytes are
unchanged.

The status line now polls that same loader every two seconds, so an edit shows
up in the running TUI. mode is the hard case: the two modes are not two
renderings 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.ts records OpenAI's published
per-seat ratios (Pro 20x, Business Premium / legacy Pro 5x, Plus and Business
Standard 1x) so the total means something; docs/plan-allotments.md documents
where those figures come from. A plan that states no ratio - Free, Go,
Enterprise, and a bare business that names a workspace rather than a seat -
returns "unknown" rather than a guess, so a caller can tell 1x apart from "we
do 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 / leftPercent fields 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. layout chooses between one segment per account, one per distinct
percentage, or a bare count. accountNames chooses between #1, the name the
account's owner gave it, and nothing. order arranges by number, by consumption
in either direction, or by which renews first or last. allotment states what
the weighted pool adds up to. resetTimes grows from a boolean into
never/low/always.

aggregate is the one worth showing. On a pool where several seats are spent,
100% 3d, 100% 4d, 100% 5d spends two thirds of its characters repeating a
number that does not change:

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.

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, while the account six days out is the
one worth spending it on.

Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com

Narrow terminals lose less than they did. 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.

None of quotaStatus is overridable by environment variable, and the
CODEX_AUTH_QUOTA_STATUS* overrides this PR previously added are removed. It is
a display preference belonging to a person rather than to whichever shell
started OpenCode, and modelAccountPools sets 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 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 (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 text node with a
newline in it so the renderer measures it and the node sizes itself.

Config surface (all optional, all defaulting to current behaviour):

Key Default Env
quotaDisplay free CODEX_AUTH_QUOTA_DISPLAY
quotaStatus.mode active (file only)
quotaStatus.rotateMs 5000 (file only)
quotaStatus.layout accounts (file only)
quotaStatus.accountNames number (file only)
quotaStatus.order number (file only)
quotaStatus.multipliers false (file only)
quotaStatus.allotment false (file only)
quotaStatus.resetTimes low (file only)
quotaStatus.resetCredits false (file only)
quotaStatus.recovery false (file only)
quotaStatus.rows 1 (file only)
quotaStatus.showFor always (file only)

Testing

  • npm run lint
  • npm run build
  • npm test

npm run lint and npm run typecheck exit 0. npm test on this branch:
3737 passed, 1 skipped, 2 failed - both failures are in
test/index-retry.test.ts and are pre-existing, not caused by this change:
they reproduce on unmodified main under full-suite load and pass when that
file 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.ts and test/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 file
underneath a running session - no restart at any point. That is also where four
defects turned up that no unit test would have caught:

  • At 165 columns the line was ellipsized through its middle, because the width
    budget was the terminal's rather than the prompt row's.
  • A one-row line sat on the bottom row of a two-row prompt, leaving the top row
    empty.
  • Switching screens left the line blank until the next unrelated change: the
    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.
  • An earlier rows: "auto" that took its height from the model label beside it
    latched at two rows permanently, because alignItems: stretch makes that label
    report this line's own height. That is why rows is a plain ceiling now.

The final pass covers all thirteen fields, both quotaDisplay wordings,
maskEmail, the aggregate and count layouts, all five orders, the rotation
between 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

  • This change stays within the repository scope and OpenAI Terms of Service expectations.
  • This change uses official authentication flows only and does not add bypass, scraping, or credential-sharing behavior.
  • I updated tests and documentation when the change affected users, maintainers, or repository behavior.

No new network calls, no new endpoints, and no change to authentication. The
pool snapshot reuses the existing /wham/usage read the quota guard already
performs.

On identity: by default the line numbers accounts the way codex-list and
codex-switch already do, and renders no email. Two of the new settings can
print one, and both honour maskEmail. accountNames: "label" prefers a label
set with codex-label and falls back to the local part of the email
(damian@nowaker.net -> damian, or da*** when masking is on). The resets
screen 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 #1 on a
narrow terminal, and it appears only when the pool is spent and something is
redeemable. Neither is on by default. The pool cache gained email and label
fields to support this; it is written mode 0600 like every other file the
plugin writes.

Notes

  • Linked issue: none
  • Follow-up work or rollout notes: docs/plan-allotments.md is the single place
    to revise when OpenAI changes per-seat pricing or ratios; the allotment map is
    deliberately reusable, so a future change could also surface 5x / 20x
    badges in codex-list, codex-status and generated account labels.

This PR is AI generated, but under direct supervision and on request of Nowaker.

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

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant


Back | FazBrowse Home | New Git URL