Skip to content

Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847

Open
heavygee wants to merge 7 commits into
tiann:mainfrom
heavygee:feat/codex-usage-indicator-rebased
Open

Codex usage indicator with cross-flavor budget gauge shape (rebase of #537)#847
heavygee wants to merge 7 commits into
tiann:mainfrom
heavygee:feat/codex-usage-indicator-rebased

Conversation

@heavygee

@heavygee heavygee commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

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:

  • 8f5f164e Add Codex session list/resume flow to web new-session UI
  • 3f4f33dc restore Codex session history
  • 2f32998b add Codex usage indicator

The remaining 4 commits are follow-up work on top:

  • 753b51b8 feat: surface credits + rate_limit_reached_type for premium accounts
  • c448e537 fix: drop $ prefix + format credit balance (rate-card-correct rendering)
  • b0fad40e fix: ring shows worst constraint, not just context
  • 97dc5414 refactor: split ring meaning - centre = context, colour = effective state

Why 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:

  1. 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.

  2. 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'.

  3. 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) gains credits (hasCredits / unlimited / balance) plus optional rateLimitReachedType / planType / limitId. JSON-only, no SCHEMA_VERSION bump.
  • normalizeCodexUsage (cli) extracts those fields from the token_count payload's rate_limits root.
  • Credits rendered as a bare count, no $ prefix. Codex's rate card (reference) makes credits a token-mix-dependent billing unit (~$0.04/credit at Pro tier, not 1:1 USD). String-to-number parser handles the precision-preserving balance shapes codex sends ("250.0000000000", "0", "0.0000000000").

Ring metaphor: centre = task room, colour = account constraint

  • New AgentBudgetState shape under shared/src/agentBudget.ts. Flavor-agnostic - declares only axes (each with pressure 0-100), an operationalAxisId (which axis the centre number shows), and an effective state (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.
  • Adapter encodes the Codex-Pro covering rule: subscription window at cap AND credits > 0 → amber, not red, with the credits axis flagged covering. The popover renders a left-accent on the covering axis so the user sees why the gauge isn't red.
  • Centre number is always the operational axis (context for LLM agents). Ring colour is always the effective state. The two no longer fight; the centre number never silently changes meaning.

Cross-flavor seed (umbrella #846)

  • AgentBudgetState is intentionally not Codex-specific. A toClaudeBudgetState adapter (or Cursor / Gemini equivalents) can drop in without touching the indicator. The Codex adapter is the reference implementation; Claude is queued next.

Visual change

State Before After
Context 80%, no rate-limit data Ring 80 blue Ring 80 blue (unchanged)
Context 80%, weekly 100%, credits 246 Ring 80 blue (silently hides the weekly cap) Ring 80 amber; popover shows 1 Week Usage 100% (dominant, accented) and Credits 246 covering exhausted subscription window (blue accent)
Context 80%, no windows, credits 0 (blocked) Ring 80 blue (silently misrepresents block) Ring 80 red; tooltip 'Blocked: subscription window and credits both exhausted'; popover shows Credits 0 subscription / top-up exhausted (critical, dominant)

Testing

  • bun typecheck + bun run test: 114 test files, 958 tests, all pass on this branch
  • Manual: validated against a live Codex Pro account on the operator's session - weekly at 100%, 246 credits, indicator correctly reads amber with covering accent
  • New test coverage: 13 tests in codexBudgetAdapter.test.ts covering 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 refreshed codexUsageSchema.test.ts and codexUsage.test.ts for the cli normalizer

Known follow-ups (out of scope for this PR)

  • i18n: the indicator strings ('Context Window', '5h Usage', 'Credits', 'subscription / top-up exhausted', etc) are English-only. The original PR was English-only as well; the cross-flavor refactor preserves that. Should route through t() in a follow-up so zh-CN locale gets coverage too.
  • account/rateLimits/updated standalone events: appServerEventConverter.ts currently only surfaces rate limits when piggy-backed on thread/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.
  • Claude / Cursor / Gemini adapters: the AgentBudgetState shape 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

dsus4wang and others added 7 commits June 8, 2026 19:50
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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Codex history import flag is dropped before runner launch — the new web resume flow sends importHistory: true and the hub includes it in the machine RPC payload, but the machine-side SpawnHappySession handler never reads or forwards that field into spawnSession. As a result, remote web resumes execute hapi 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 at cli/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 importHistory reaches spawnSession.

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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
})

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.

2 participants