Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847
Open
heavygee wants to merge 7 commits into
Open
Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847heavygee wants to merge 7 commits into
heavygee wants to merge 7 commits into
Conversation
Ethan's indicator (tiann#537) was designed for time-window plans (plus / pro 5h+weekly). On Codex Pro accounts that exhaust the subscription windows AND any topped-up credits, the app-server emits rate_limits.primary=null + secondary=null + credits.has_credits=false + balance="0", and the indicator silently fell back to context-window- only - reading "80% context, plenty of room" while the account was actually blocked. Extend the data path end-to-end: shared/schemas - add CodexUsageCreditsSchema (hasCredits / unlimited / balance) and optional rateLimitReachedType / planType / limitId on CodexUsageSchema. JSON-only, no SCHEMA_VERSION bump. cli/codexUsage - normalize credits + reached_type + plan_type + limit_id from the rate_limits root regardless of whether primary / secondary are present. web/codexUsageDisplay - add isCodexUsageBlocked() helper; force ring to 100% and color red when blocked; render a critical-severity "Credits" row with $balance + 'subscription / top-up exhausted' detail; render a critical-severity "Limit Reached" header when codex sets rate_limit_reached_type. Unlimited credit accounts read "Unlimited" and stay green. Covered by 4 new cli tests (premium-credits shape from a real Codex Pro rollout, plus reached-type + unlimited-credits cases), 3 new web tests (blocked-state ring forcing, Limit Reached header, unlimited non-blocking), and 1 new shared schema test. Co-authored-by: Cursor <cursoragent@cursor.com>
Codex sends 'balance' as a precision-preserving string ('250.0000000000',
'0', '0.0000000000') with no declared unit. The previous render asserted
USD with a $ prefix and dumped the string verbatim, producing the
visually awful '$250.0000000000'.
Credits are an internal billing token per the OpenAI Codex rate card
(https://help.openai.com/en/articles/20001106-codex-rate-card): GPT-5.5
consumes 125 credits per 1M input tokens / 750 per 1M output, and a $5
top-up grants 125 credits (~$0.04/credit, not the $1/credit the prior
comment fabricated). Chatgpt.com's own UI even renders credits and any
USD conversion separately ('246 credits, ~10-62 cloud messages'), so
prefixing $ on the balance is a flat-out type error.
- formatCreditsBalance(): Number-parse + toLocaleString with 2dp cap
for values >= 1 (4dp for sub-unit balances), trailing zeros trimmed.
'250.0000000000' -> '250', '12.345' -> '12.35', '0.0000000000' -> '0'.
- Drop the $ prefix; the row label 'Credits' carries the unit.
- isCodexUsageBlocked / exhausted-detail check both now parse balance
numerically instead of literal-matching '0' / '0.00', so future
trailing-zero variants ('0.0000000000') cannot slip past.
Adds a 4-case parametrized test covering the real string shapes observed
in the wild plus a decimal-rounding case.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ethan's ring (PR tiann#537) preferred contextWindow.percent and only fell back to rate-limits when context was absent. Real consequence: weekly=100% but ctx=80% rendered as a green '80' ring, hiding a HARD subscription cap behind a SOFT context fill. Then when both windows AND credits exhausted, the blocked override jumped the ring to red 100 - so the same circle silently switched semantics from 'context fill' to 'usage exhaustion' mid-session. Operator caught it: 'the circle that WAS showing you context now shows you red 100 meaning no more usage'. Make the ring mean one thing across all states: 'percent of the most-pressing limit you're about to hit'. - New getCodexUsageRing(): max across context + 5h + weekly (blocked still forces 100). Returns { percent, axis } so callers can show which constraint is in front. - getCodexUsageRingPercent() kept as a thin wrapper for any future callers that only need the number. - getCodexUsageRingTitle(): axis-aware aria-label + title so hovering the ring tells you 'Weekly subscription window 100% used' instead of 'Codex usage' regardless of state. - getCodexUsageRows() marks the dominant row (dominant: true). Popover paints a left-accent bar + bolds the matching label so opening it immediately answers 'why is the ring at 100?'. - Ring colour gains an amber intermediate (60-85%) instead of jumping straight from blue to red at 85. Replaces the 'prefers context' + 'falls back to rate-limits' tests with three new ones covering the bug shape (ctx 80 vs weekly 100), the inverse (context dominates), and dominant-row marking. Co-authored-by: Cursor <cursoragent@cursor.com>
…= effective state Introduces a flavor-agnostic AgentBudgetState shape under shared/ and refactors the Codex indicator to consume it via a Codex-specific adapter. This is the seed of the cross-flavor agent budget gauge umbrella (tiann#846); Claude / Cursor / Gemini adapters can drop in without touching the renderer. Why Co-authored-by: Cursor <cursoragent@cursor.com> --- The previous ring conflated two questions in one number: 1. 'How much room for THIS task?' (context fill) 2. 'Am I about to be blocked?' (rate-limit / credits state) The number's semantics silently flipped between them based on account state - context% in the normal case, 100 in the blocked case - so the same circle meant different things at different times. Worse, a Pro account with subscription windows at 100% but credits available read red 100 (technically true: weekly is capped) when the operationally correct signal was amber (credits cover the overage, user is not actually blocked). Design ------ - AgentBudgetState.operationalAxisId picks the always-visible centre number (defaults to context for LLM agents). Stays consistent across all states; the number no longer changes meaning. - AgentBudgetState.effective is the per-flavor verdict (green / amber / red / blocked) computed by the adapter using its specific blocking rules. The renderer paints the ring colour from this; user gets one honest 'are you about to be blocked' signal alongside the operational centre number. - Adapter encodes the Codex-Pro covering rule: subscription window at cap AND credits > 0 -> amber, not red. The popover marks the credits axis as 'covering' with a blue accent so the user can see why. - Popover renders pressure axes top-to-bottom with the dominant one carrying a colour-coded left accent + bold label, then a divider, then the informational metadata rows (token breakdown etc). Generalisation seed ------------------- - shared/src/agentBudget.ts: flavor-agnostic types (AgentBudgetState / Axis / EffectiveState / MetadataRow). - web/src/components/AssistantChat/AgentBudgetIndicator.tsx: renderer that consumes AgentBudgetState. Knows nothing about codex credits or claude rate-limit headers - drop in a new adapter and the indicator works for that flavor. - web/src/components/AssistantChat/codexBudgetAdapter.ts: toCodexBudgetState() - the only Codex-flavor module under web/. All 5h / weekly / credits / plan_type semantics live here. Removes the previous getCodexUsageRing / getCodexUsageRows / getCodexUsageRingTitle / CodexUsageRing / CodexUsageRow helpers (replaced by the adapter) plus their test file. 11 new tests in codexBudgetAdapter.test.ts cover the operator-caught scenarios: weekly at cap + credits covering -> amber, blocked, unlimited credits, fresh session before context arrives, etc.
There was a problem hiding this comment.
Findings
- [Major] Codex history import flag is dropped before runner launch — the new web resume flow sends
importHistory: trueand the hub includes it in the machine RPC payload, but the machine-sideSpawnHappySessionhandler never reads or forwards that field intospawnSession. As a result, remote web resumes executehapi codex resume <thread>without--hapi-import-history, so the newly selected Codex transcript history is not imported into the fresh HAPI session. Evidence:hub/src/sync/rpcGateway.ts:142, with the drop atcli/src/api/apiMachine.ts:252-277.
Suggested fix:const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName, importHistory } = params || {} const result = await spawnSession({ directory, sessionId, resumeSessionId, importHistory, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName })
Questions
- None.
Summary
- Review mode: initial
- Found one Major issue in the remote Codex resume/import path. Residual risk: I did not run the full test suite in this automation pass; add a machine RPC boundary test that asserts
importHistoryreachesspawnSession.
Testing
- Not run (automation)
HAPI Bot
| machineId, | ||
| RPC_METHODS.SpawnHappySession, | ||
| { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode } | ||
| { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, importHistory, effort, permissionMode } |
There was a problem hiding this comment.
[Major] This adds importHistory to the hub-to-machine RPC payload, but the machine-side SpawnHappySession handler never destructures or forwards it into spawnSession, so the runner never emits --hapi-import-history for web-selected Codex resumes. That means the new session selector resumes the Codex thread but does not import the existing transcript history.
Suggested fix:
const {
directory,
sessionId,
resumeSessionId,
machineId,
approvedNewDirectoryCreation,
agent,
model,
effort,
modelReasoningEffort,
yolo,
permissionMode,
token,
sessionType,
worktreeName,
importHistory
} = params || {}
const result = await spawnSession({
directory,
sessionId,
resumeSessionId,
importHistory,
machineId,
approvedNewDirectoryCreation,
agent,
model,
effort,
modelReasoningEffort,
yolo,
permissionMode,
token,
sessionType,
worktreeName
})
This was referenced Jun 9, 2026
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.
What
Rebases @dsus4wang's stalled #537 onto current
main(179 commits caught up since April), then extends the indicator to handle premium-credits accounts, fix unit-rendering bugs, and lay a flavor-agnostic shape for the cross-flavor budget gauge umbrella (#846).Original PR by @dsus4wang. The first 3 commits on this branch preserve his authorship intact:
8f5f164eAdd Codex session list/resume flow to web new-session UI3f4f33dcrestore Codex session history2f32998badd Codex usage indicatorThe remaining 4 commits are follow-up work on top:
753b51b8feat: surface credits + rate_limit_reached_type for premium accountsc448e537fix: drop $ prefix + format credit balance (rate-card-correct rendering)b0fad40efix: ring shows worst constraint, not just context97dc5414refactor: split ring meaning - centre = context, colour = effective stateWhy the extension
Operator-tested @dsus4wang's indicator against a live Codex Pro account on 2026-06-09. Three issues surfaced that the original PR couldn't have caught on a Plus-tier test account:
Premium-credits accounts have no time-window rate limits. When subscription is exhausted and billing falls back to credits, codex emits
rate_limits.primary=null+secondary=null+credits.has_credits=false. The original indicator was silent on this entire state - showed only the context-window % even when the account was hard-blocked.Ring semantics drifted between states. The same circle meant 'context fill' in the normal case but 'usage exhaustion' when blocked. Operator caught it: 'the circle that WAS showing you context now shows you red 100 meaning no more usage'.
A Pro account that exhausts the subscription window but tops up credits is not blocked - codex falls back to credit billing transparently. The original would have read this as 'weekly 100%, red, you're done' even though the user can keep sending. False alarm vs false safety in the same widget.
What changed
Premium-credits support
CodexUsageSchema(shared) gainscredits(hasCredits/unlimited/balance) plus optionalrateLimitReachedType/planType/limitId. JSON-only, noSCHEMA_VERSIONbump.normalizeCodexUsage(cli) extracts those fields from thetoken_countpayload'srate_limitsroot.Ring metaphor: centre = task room, colour = account constraint
AgentBudgetStateshape undershared/src/agentBudget.ts. Flavor-agnostic - declares onlyaxes(each withpressure 0-100), anoperationalAxisId(which axis the centre number shows), and aneffectivestate (green/amber/red/blocked) computed per-flavor.AgentBudgetIndicator(web) is a flavor-agnostic renderer that consumes the state. The Codex adapter (codexBudgetAdapter.ts) is the only Codex-specific module on the web side.covering. The popover renders a left-accent on the covering axis so the user sees why the gauge isn't red.Cross-flavor seed (umbrella #846)
AgentBudgetStateis intentionally not Codex-specific. AtoClaudeBudgetStateadapter (or Cursor / Gemini equivalents) can drop in without touching the indicator. The Codex adapter is the reference implementation; Claude is queued next.Visual change
80blue80blue (unchanged)80blue (silently hides the weekly cap)80amber; popover shows1 Week Usage 100%(dominant, accented) andCredits 246 covering exhausted subscription window(blue accent)80blue (silently misrepresents block)80red; tooltip 'Blocked: subscription window and credits both exhausted'; popover showsCredits 0 subscription / top-up exhausted(critical, dominant)Testing
bun typecheck+bun run test: 114 test files, 958 tests, all pass on this branchcodexBudgetAdapter.test.tscovering the operator-caught scenarios (premium-credits shape, covering / blocked / red transitions, transition-window edge where both subscription axes are present at 100 + credits 0, unlimited credits exemption, etc); plus refreshedcodexUsageSchema.test.tsandcodexUsage.test.tsfor the cli normalizerKnown follow-ups (out of scope for this PR)
t()in a follow-up so zh-CN locale gets coverage too.account/rateLimits/updatedstandalone events:appServerEventConverter.tscurrently only surfaces rate limits when piggy-backed onthread/tokenUsage/updated. Standalone rate-limit-update events from the codex app-server are dropped. Worth surfacing in a follow-up so rate-limit transitions reach the UI without waiting for the next turn.AgentBudgetStateshape is the cross-flavor foundation. Claude adapter is queued next (depends on confirming Anthropic ratelimit headers reach HAPI's hook stream); Cursor is data-blocked until they expose telemetry; Gemini follows.Related
Made with Cursor