Skip to content

feat(composer): show provider account usage in the input area - #5945

Closed
gfsaaser24 wants to merge 3 commits into
pingdotgg:mainfrom
gfsaaser24:feat/provider-usage-meters
Closed

feat(composer): show provider account usage in the input area#5945
gfsaaser24 wants to merge 3 commits into
pingdotgg:mainfrom
gfsaaser24:feat/provider-usage-meters

Conversation

@gfsaaser24

@gfsaaser24 gfsaaser24 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Up front: this is a very large feature PR (~2,650 lines), which your contributing guide says not to open. I am opening it anyway because the feature is finished, tested, and stable rather than a sketch, and it seemed more useful to you as working code than as an issue describing it. It is a UX addition, not a bug fix. Please close it without ceremony if it is not a direction you want — no reply needed, and I will not re-open or follow up.

What Changed

Adds account usage meters to the composer.

Contract — a provider-agnostic ProviderUsageLimits in packages/contracts, shaped { id, label, usedPercent, resetsAt }, so clients map over a list instead of branching per provider.

Server — an in-memory ProviderUsageLimitsStore fed by two sources:

  • ProviderUsageIngestion consumes the account.rate-limits.updated runtime events the Claude and Codex adapters already emit and nothing currently reads. Zero extra network calls.
  • ProviderUsageRefresher does a debounced (60s/instance) pull for Claude only, since Claude reports usage only on request. It reads the token the claude CLI already stored and calls the OAuth usage endpoint, which reports utilization without spending message quota. Codex needs no pull — it volunteers numbers over the app-server connection.

Provider-shaped payloads are flattened in usageLimits.ts. Every normalizer is total: anything unparseable yields no windows rather than an error.

Web — a ring per window beside the send button, collapsing to the worst window when the composer is narrow. Hover gives exact percentages and reset times.

Mobile — one toolbar button instead of three 44pt touch targets, listing the windows on tap.

Providers that do not report usage (Cursor, Grok, OpenCode) render nothing at all, so switching threads between providers costs no layout shift.

Why

Right now you find out you are out of quota when a turn stops halfway through. The data to prevent that is already flowing — both adapters emit rate-limit events at the end of every turn and nothing consumes them.

Design decisions worth flagging, since they are the ones you would push back on:

  • Nothing is persisted. A usage number is only interesting while it is roughly current, and both feeds repopulate within one turn of a restart.
  • The store only announces when a number actually moves. updatedAt is stamped server-side on every reading, so publishing on the stamp would wake the registry and push the entire provider array — every model, capability, and skill — to every connected client once per turn, for no visible change.
  • No tickers. Relative labels ("resets in 2h 15m") are stamped when the popover opens, not on an interval. A continuously repainting composer pegs the GPU on high-refresh displays.
  • Every failure path draws nothing. No credentials, locked keychain, expired token, network blip — all resolve to blank meters. There is no error a user could act on here.

UI Changes

Hovering a meter in the web composer:

Claude usage popover in the composer, showing Session at 0%, Weekly at 17%, and Fable at 16% with reset times, beside the send button

Before is the same composer with no circles and no popover — the meters are purely additive, and providers that report no usage still render exactly that.

No motion or transitions were added, so there is no video.

Testing

Branched off main at 3d74474f. Focused tests for the normalizers, the store's merge/debounce/announce behavior, the ingestion seam, the token reader, and the web components. Typecheck and lint clean across server, web, mobile, contracts, and client-runtime.

Two pre-existing failures in ProviderRegistry.test.ts and server.test.ts reproduce identically on unmodified main on my machine — they look like Windows-only arg-quoting artifacts in the spawn mocks (Unexpected args: ^"--version^"), unrelated to this change.

Checklist

  • This PR is small and focused — it is not; stated plainly above
  • I explained what changed and why
  • I included a screenshot for the UI change
  • I included a video for animation/interaction changes — no motion added

Note

Medium Risk
Large cross-cutting feature touching provider registry, OAuth credential reads, and macOS keychain subprocesses; failures are designed to degrade to empty meters rather than block turns.

Overview
Adds account quota usage to the composer on web and mobile, driven by a new optional usageLimits field on ServerProvider and a serverRefreshProviderUsage RPC that schedules a background pull and returns immediately.

On the server, a volatile in-memory store merges full vs partial readings from turn-time account.rate-limits.updated events (Claude/Codex) and from Claude-only on-demand pulls (OAuth token from the CLI credentials path or macOS keychain, debounced per instance). Codex also seeds usage during status probe via account/rateLimits/read with a tight timeout. The provider registry decorates snapshots from the store, republishes on usage-only changes without disk writes, strips usage from status cache persistence, and clears stored usage when an instance is rebuilt or removed.

Web shows ring meters beside send (compact = worst window); mobile uses one gauge menu in the toolbar. ControlPillMenu gains a render-prop child so Android triggers stay tappable. Shared formatting/selection lives in @t3tools/client-runtime/state/provider-usage.

Reviewed by Cursor Bugbot for commit 75a8a88. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add provider account usage meters to the composer input area

  • Adds per-provider usage windows (percent used, reset time) to the web and mobile composer toolbars, shown as circular ring meters that turn critical above 90%.
  • Introduces a server-side in-memory ProviderUsageLimitsStore that tracks usage per provider instance, populated via runtime turn events (ProviderUsageIngestionLive) and on-demand HTTP fetches (ProviderUsageRefresher) for Claude and Codex.
  • Adds a new serverRefreshProviderUsage WebSocket RPC that schedules a background usage refresh without blocking the caller; client-side calls are deduplicated with single-flight keying.
  • Usage is refreshed on composer mount, on window/app focus, and when the usage popover is opened; server-side refreshes are debounced to once per 60 seconds per instance.
  • Usage data is decorated onto ServerProvider snapshots in the registry and propagated to clients without disk persistence or waiting for full status probes.

Macroscope summarized 75a8a88.

You find out you are out of quota when a turn stops halfway through.
Both the Claude and Codex adapters already emit
`account.rate-limits.updated` at the end of every turn and nothing
consumed it, so the numbers were sitting there unused.

Adds a provider-agnostic `ProviderUsageLimits` contract
(`{ id, label, usedPercent, resetsAt }`) and two feeds into an in-memory
store: the free turn events, and a debounced on-demand pull for Claude,
whose OAuth usage endpoint reports utilization without spending message
quota. Web draws a ring per window beside the send button, collapsing to
the worst one when the composer is narrow; mobile gets a single toolbar
button that lists the windows on tap. Providers that report no usage —
Cursor, Grok, OpenCode — render nothing rather than an empty circle.

Readings are never persisted, and the store only announces a change when
a number actually moves, so a provider re-reporting identical usage does
not push the whole provider array to every client once per turn.

Written by Claude Opus 5 in Claude Code.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d0a44efc-4457-4027-b4d1-aacb2d5efa54

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Aug 10, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Effect service conventions review of the new provider-usage services. Four findings, all in the new service/layer modules: standalone *Shape interfaces instead of inline Context.Service interfaces, and new services split across Services/ + Layers/ with *Live layer names rather than one canonical module exporting make/layer.

Posted via Macroscope — Effect Service Conventions


const decodeClaudeSettings = Schema.decodeUnknownEffect(ClaudeSettings);

export const ProviderUsageRefresherLive = Layer.effect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same layout issue, plus the tag for this service lives in a differently named module (Services/ProviderUsageLimits.ts). Consider a single canonical apps/server/src/provider/ProviderUsageRefresher.ts holding the Context.Service tag, an exported make, and export const layer = Layer.effect(ProviderUsageRefresher, make) rather than ProviderUsageRefresherLive.

Posted via Macroscope — Effect Service Conventions

ProviderUsageLimitsStoreShape
>()("t3/provider/Services/ProviderUsageLimits/ProviderUsageLimitsStore") {}

export interface ProviderUsageRefresherShape {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here: ProviderUsageRefresherShape should be inlined into the Context.Service declaration below, with consumers using ProviderUsageRefresher["Service"] (as server.test.ts already does for the mock) rather than the exported shape type.

Posted via Macroscope — Effect Service Conventions

*/
export const USAGE_REFRESH_DEBOUNCE_MS = 60_000;

export const ProviderUsageLimitsStoreLive = Layer.effect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This new service is split across Services/ProviderUsageLimits.ts (tag) and Layers/ProviderUsageLimits.ts (construction + layer). For newly added services the tag, construction, and layer belong in one canonical module — e.g. apps/server/src/provider/ProviderUsageLimits.ts — exporting a real make plus export const layer = Layer.effect(ProviderUsageLimitsStore, make) instead of ProviderUsageLimitsStoreLive.

Posted via Macroscope — Effect Service Conventions

import type * as Scope from "effect/Scope";
import type * as Stream from "effect/Stream";

export interface ProviderUsageLimitsStoreShape {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

New service definitions should declare their interface inline in the Context.Service call rather than keeping a standalone *Shape type. Consider moving these members into Context.Service<ProviderUsageLimitsStore, { ... }>() and referring to the inferred shape as ProviderUsageLimitsStore["Service"] at the implementation site (satisfies in Layers/ProviderUsageLimits.ts) instead of importing ProviderUsageLimitsStoreShape.

Posted via Macroscope — Effect Service Conventions

// capability, and skill — to every connected client, once per
// turn, for no visible change.
return [
existing === undefined || !Equal.equals(existing.windows, merged.windows),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium Layers/ProviderUsageLimits.ts:65

Equal.equals(existing.windows, merged.windows) compares ordinary JavaScript arrays by reference, not structurally. applyUsageReading returns a new array on every call, so even an identical re-report of usage is treated as changed and PubSub.publish fires every turn. The change suppression the comment describes is ineffective, so unchanged usage readings trigger the registry to rebuild and broadcast the full provider snapshot to every client. Use an explicit structural comparison for the windows arrays (or use data types implementing Equal).

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderUsageLimits.ts around line 65:

`Equal.equals(existing.windows, merged.windows)` compares ordinary JavaScript arrays by reference, not structurally. `applyUsageReading` returns a new array on every call, so even an identical re-report of usage is treated as changed and `PubSub.publish` fires every turn. The change suppression the comment describes is ineffective, so unchanged usage readings trigger the registry to rebuild and broadcast the full provider snapshot to every client. Use an explicit structural comparison for the windows arrays (or use data types implementing `Equal`).

Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx
Comment thread apps/web/src/components/chat/ChatComposer.tsx
Comment thread apps/server/src/provider/Layers/ProviderUsageLimits.ts
Comment thread apps/server/src/provider/claudeUsageFetch.ts Outdated
Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx
Comment thread apps/server/src/provider/Layers/ProviderUsageRefresher.ts
@macroscopeapp

macroscopeapp Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces a substantial new feature (provider usage meters) with new UI components, server services, RPC endpoints, and OAuth credential reading. The scope includes ~3000 lines of new code and multiple unresolved Medium-severity findings about correctness issues. New feature introduction of this complexity warrants human review.

You can customize Macroscope's approvability policy. Learn more.

Four real ones from the bot pass:

- The Android usage menu never ran its own onPress. `AndroidAnchoredMenu`
  wraps a plain child in a `pointerEvents="none"` view so the anchor owns
  the tap, so opening the menu never restamped the relative labels or
  asked for a fresh reading. `ControlPillMenu` now also accepts the
  render-function child form, which keeps the trigger interactive there.
- The web popover titled one provider's quota with another's name. The
  meters read the selected instance (the choice for the next turn) while
  the title read the thread's persisted provider, so picking a new model
  swapped the numbers but not the label.
- `claimRefreshSlot` wedged when the wall clock moved backwards: a
  negative elapsed read as "still inside the debounce window" and blocked
  refreshes until the clock caught up.
- `fetchClaudeUsage` only bounded `client.execute`, so a 2xx response
  with a trickled body parked the refresh fiber past the timeout.

Written by Claude Opus 5 in Claude Code.
@gfsaaser24

Copy link
Copy Markdown
Contributor Author

Pushed 55e6796a. Four of the bot findings were real and are fixed; seven are wrong, with reasoning below so you don't have to re-derive it.

Fixed

Android usage menu never ran its own onPress (Macroscope + Cursor Bugbot, independently — good catch). AndroidAnchoredMenu wraps a plain child in a pointerEvents="none" view under the anchor Pressable, so the trigger's handler was unreachable and opening the menu neither restamped the relative labels nor requested a reading. The same component's render-function branch keeps children interactive, so ControlPillMenu now accepts (open) => ReactNode as well and the usage trigger uses it. open is a no-op on iOS, which opens natively. Purely additive — existing ReactNode callers are untouched.

Web popover attributed one provider's quota to another. The meters read selectedInstanceId (which prefers the live model-picker choice) while the title read the thread's persisted provider, so between picking a model and sending, the new provider's numbers sat under the old provider's name. Added a display name derived from the same instance the windows come from.

claimRefreshSlot wedged on a backwards wall clock. now - lastRefreshAt goes negative after an NTP correction and read as "still inside the debounce window," blocking refreshes until the clock caught up. Now requires a non-negative elapsed. Regression test added with TestClock.setTime.

fetchClaudeUsage timeout didn't cover the body. The 10s bound wrapped client.execute only, so a 2xx response with a trickled body parked the refresh fiber indefinitely. The timeout now wraps execute-and-decode together.

Not fixed, because the bots are wrong

Equal.equals(existing.windows, merged.windows) "compares by reference" — this is incorrect on effect 4. Two separate bots have now made this call, both reasoning from effect 3 semantics. In the pinned effect@4.0.0-beta.103, Equal.ts dispatches plain arrays to compareArrays and plain objects to compareRecords; the comparison is deep. The existing test "does not announce a change when only the timestamp moved" asserts exactly the behavior the bot claims is broken, and it passes. Nothing to change.

Effect service conventions ×4 — the suggestion is to collapse the tag, construction, and layer into one canonical module and inline the *Shape interfaces. That is the opposite of the convention in the directory these services were added to: apps/server/src/provider/ currently holds 14 services all built the same way, Services/<Name>.ts with the tag and a <Name>Shape interface, Layers/<Name>.ts with construction and <Name>LiveClaudeAdapter, CodexAdapter, ProviderRegistry, ProviderService, ProviderSessionReaper, and so on. Adopting the bot's shape would make these two the only inconsistent services in the subtree. Happy to move the whole directory if you'd rather standardize the other way, but that is not this PR.

"Refresh debounce burned without a token" — the sequence is real but the current ordering is the safer one. Claiming the slot before reading credentials is what stops ambient UI triggers (window focus, thread switch, meter hover) from driving an unbounded number of macOS security keychain invocations while signed out, each of which can raise an interactive prompt. A signed-out user waiting up to 60s for meters to appear after logging in is the cheaper side of that trade.

Tests, typecheck, and lint are clean on the new commit. The two pre-existing failures I mentioned in the description still reproduce on unmodified main on my machine and are unrelated.

@gfsaaser24 gfsaaser24 changed the title feat(composer): show provider account usage in the composer feat(composer): show provider account usage in the input area Aug 10, 2026
@gfsaaser24

Copy link
Copy Markdown
Contributor Author

revisions inbound. leave open please.

Remaining findings from the bot pass:

- A rebuilt or removed instance kept its stored usage reading. Rebuilds
  can point at a different account, and `applyProviderUsageLimits`
  decorated the new configuration's snapshots with the old numbers until
  a fresh reading happened to land. The store gains `clear`, and
  `syncLiveSources` forgets every instance it does not carry over
  unchanged. The usage-change subscriber now strips the previous
  decoration before re-upserting — left on, it re-seeded whatever the
  store held last, which resurrected cleared readings the moment the
  clear announced itself.
- A present-but-malformed percent alias (`used_percentage: "abc"`)
  masked a readable later one; each alias now runs through the parser
  in turn.
- The usage-meter doc described the hover value as the exact
  percentage; it is the displayed (rounded) one.

Written by Claude Fable 5 in Claude Code.
Comment on lines +106 to +108
resetsAt: readIsoTimestamp(
value.resets_at ?? value.resetsAt ?? value.reset_at ?? value.resetAt,
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium provider/usageLimits.ts:106

readClaudeBucket parses the reset-time aliases with ??, so a present-but-malformed resets_at value masks a valid resetsAt fallback. For input like { resets_at: "invalid", resetsAt: "2026-08-12T00:00:00Z" }, readIsoTimestamp receives "invalid", returns null, and the valid resetsAt timestamp is silently dropped — producing a missing reset label even though a usable value was present. The percentage aliases just above correctly avoid this by running each candidate through readPercent. Consider applying the same reduce-through-parser pattern to the reset aliases.

Suggested change
resetsAt: readIsoTimestamp(
value.resets_at ?? value.resetsAt ?? value.reset_at ?? value.resetAt,
),
resetsAt: [value.resets_at, value.resetsAt, value.reset_at, value.resetAt].reduce<string | null>(
(found, candidate) => found ?? readIsoTimestamp(candidate),
null,
),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/usageLimits.ts around lines 106-108:

`readClaudeBucket` parses the reset-time aliases with `??`, so a present-but-malformed `resets_at` value masks a valid `resetsAt` fallback. For input like `{ resets_at: "invalid", resetsAt: "2026-08-12T00:00:00Z" }`, `readIsoTimestamp` receives `"invalid"`, returns `null`, and the valid `resetsAt` timestamp is silently dropped — producing a missing reset label even though a usable value was present. The percentage aliases just above correctly avoid this by running each candidate through `readPercent`. Consider applying the same reduce-through-parser pattern to the reset aliases.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 75a8a88. Configure here.

if (hadReading) {
yield* PubSub.publish(changes, instanceId);
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clear loses to in-flight sets

Medium Severity

clear drops a rebuilt instance’s reading, but it does not invalidate in-flight writers. A Claude refresh can resolve settings and claim a slot, then finish its OAuth pull after rebuild and call set with the previous account’s numbers. The same hole exists for a snapshot syncProvider that still carries baked-in usageLimits. Meters can show the old account again after a config change that was supposed to wipe them.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 75a8a88. Configure here.

@gfsaaser24

Copy link
Copy Markdown
Contributor Author

Superseded by #6080 — the same feature re-cut as one clean commit on current main, with all the review-bot findings from this PR folded in. Closing to keep the history readable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant