Composer usage meter, Claude plan tier, and Retry-After handling - #41
Composer usage meter, Claude plan tier, and Retry-After handling#41daveycodez wants to merge 1 commit into
Conversation
Surface Claude subscription limits where they are spent — the composer — and close two gaps that kept the reading approximate. Composer meter (src/client/UsageMeter.tsx) A ring in `conversation.input.right`, beside the shell's own ContextMeter whose geometry and panel chrome it mirrors. It tracks the limit that applies to the session's current model: a model with its own weekly limit shows that one, everything else the shared weekly pool. Amber at 75% and red at 95%, matching the Claude Code app's steps. It renders only while a Claude model is selected, so it never reports a limit the next turn will not spend. The endpoint is aggressively rate limited AND shared with the settings page, so the meter is deliberately frugal: one store serves every session (the slot renders per session, so a fetcher per component would multiply the rate), an idle session issues nothing, a running one asks every three minutes, and hovering revalidates under the same floor. Stale-while- revalidate throughout — a refusal never clears the last good reading, so the panel degrades to slightly stale numbers rather than an error card. The model gate polls `sessions.models` (a local RPC, no upstream traffic) and additionally probes just after a pointer or Enter gesture, because a model switch is always a gesture and waiting out a poll tick reads as lag. Plan tier (src/providers/claude.ts) The Claude usage payload names no tier — verified against the live endpoint, whose keys carry no plan, tier, or multiplier field, unlike codex's `plan_type`. The stored session already knows it, so the plan now comes from `subscriptionType`; `ProviderUsage.plan` needed no change. Retry-After (src/providers/common.ts, src/auth/rpc.ts) `oauthEndpointError` dropped the header, so a 429 reached the page as bare text and every caller could only guess at a backoff. It is parsed now — both the seconds and HTTP-date forms, refusing past deadlines — and rides `OAuthEndpointError.retryAfterMs`. The `internal` RpcResult branch types `details` as an empty object upstream, so crossing the RPC boundary means appending a parseable suffix to the message; the meter reads it and honours the interval the provider actually asked for. Also - Settings meters adopt the shell's bar geometry (4px, fully rounded, filled track, no border) and the 75% warning step, so a plugin-drawn progress bar reads as the control the product draws elsewhere. - A nav glyph for the Subscriptions section. The shell picks icons by section id and gives every unknown id the gear, and registration carries no icon field, so this decorates our own cell after render — matching on the live value of our own label thunk (so it follows the locale), mutating the existing svg rather than replacing React-managed nodes, and restoring the gear on disposal. Delete it the day registration takes a glyph. - Tests cover the plan tier on both payload shapes, its absence, the header parser, and the delay surviving to the caller.
Two corrections found while porting this meter upstream (V1ki/dsh-plugin-subscriptions#41). pickWindow tested for Fable by name, so the ring would have silently fallen back to the shared weekly pool the day a limit is scoped to any other model. It now compares the reported scope against the model id, so it keeps working as the plan shape changes. The retry hint now reads the `(retry-after: 300s)` suffix that PR adds to the flattened RPC message — the only channel that survives a boundary whose `details` is typed as an empty object upstream — so a 429 waits the interval the provider actually named rather than a fixed guess. The looser pattern stays for other phrasings, and an installation without that change keeps the fixed backoff. The README also corrects two claims: the Claude usage payload names no tier anywhere (checked against the live endpoint, not assumed), and a settings section CAN carry a custom nav glyph — not through registration, which has no icon field, but by decorating the rendered cell, which is what dsh-archived-chats does. Recorded in case this plugin ever grows a settings page.
The section belongs to dsh-plugin-subscriptions, which this plugin already depends on for its usage data, so fixing its icon here needs no change to that package and no fork of it. The settings shell picks nav icons by section id and falls back to the gear for every id it does not know; `settings.section` registration carries only id, order, and label, and the rendered cell exposes no section id, so the label is the only handle a decorator has. This matches the labels that plugin ships in both locales, mutates the existing svg rather than replacing React-managed nodes, and restores the gear on disposal. It also skips any row already carrying the upstream patch's marker, so if V1ki/dsh-plugin-subscriptions#41 lands and ships the same glyph, the two do not fight over the node — this simply goes inert.
|
Thanks for putting this together. I'd like to discuss how this should fit into the current UI before merging it. We've already merged a subscription usage readout in the status strip below the composer ( I'm also hesitant to introduce a dedicated Claude-only UI when the plugin supports usage reporting for other providers. A consistent entry point across providers would probably be easier for users to understand. Could we discuss integrating the useful parts of this PR—such as the detailed limits panel, reset times, and plan name—into the existing status-strip display? A few things to agree on:
The Claude plan-name improvement and HTTP-date support for |
Puts the Claude subscription reading where it is actually spent — the composer — and closes two gaps that kept it approximate.
Happy to split any part of this out if you'd rather take it in pieces.
Composer meter
A ring in
conversation.input.right, beside the shell's ownContextMeterwhose ring geometry and panel chrome it mirrors so the pair reads as one family.Resets in 4 hr 35 min,Resets Thu 11:59 AM).On request volume
The usage endpoint is aggressively rate limited and shared with the settings page, so the meter is deliberately frugal — I managed to 429 myself while developing this, which is what set these numbers:
One store serves every open session — the slot renders per session, so a fetcher per component would multiply the rate by the number of sessions. Stale-while-revalidate throughout: a refusal never clears the last good reading, so the panel degrades to slightly stale numbers rather than an error card.
On the model gate
The current model is not on the conversation snapshot, there is no model-change event in the client catalog, and the model selector's store is private to that plugin — so the gate polls
sessions.models. That is a local host RPC with no upstream traffic, so it runs at 700ms, and any pointer/Enter gesture additionally probes on a short staircase, because a switch is always a gesture and waiting out a tick reads as lag. If amodel/changeevent ever lands, this becomes exact and cheaper.Plan tier
Maxnever appeared because the Claude usage mapper returned only{ supported, windows }. I checked the live endpoint before assuming: its payload carries no plan, tier, or multiplier field anywhere — unlike codex'splan_type. The stored session already knows it, so the plan now comes fromsubscriptionType.ProviderUsage.planneeded no change.The
(20x)part of Claude Code'sMax (20x)is a multiplier the credentials do not carry, so this rendersMax. I would rather show what we know than hardcode a mapping.Retry-After
oauthEndpointErrorkept only the status and OAuth code and dropped the header, so a 429 reached the page as bare text and every caller — this meter and the settings page both — could only guess at a backoff.It is parsed now (both the seconds and HTTP-date forms, refusing past deadlines) and rides
OAuthEndpointError.retryAfterMs.One wart worth flagging: the
internalRpcResultbranch typesdetailsasz.object({})upstream indsh-host-apiproxy, so a structured field would be stripped in validation. The only channel that survives to the browser is the message, so the node half appends a parseable(retry-after: 300s)suffix and the meter reads it. It works and it is documented at both ends, but the honest fix is a details field upstream — happy to change this if you'd prefer it carried differently.Also
navIconinui-settings-general) and registration carries no icon field, so this decorates our own cell after render — matching the live value of our ownlabelthunk so it follows the locale, mutating the existing<svg>rather than replacing React-managed nodes, and restoring the gear on disposal. Every failure path leaves the shipped icon. It is a DOM decoration and I will not be offended if you drop it; delete the file the day registration takes a glyph.Testing
test/usage.spec.tsgains four cases: the plan tier on both payload shapes, its absence (missing and whitespace), the header parser across both forms, and the delay surviving to the caller.Full suite: 196 passing, 0 failing (192 before this branch; the 7 cancelled / 6 skipped are pre-existing and unchanged).
One caveat on my verification:
pnpm buildrunstsc, and I could not get a clean typecheck locally because the published@deepseek-ai/*packages ship no.d.tsfiles —typespoints at paths that do not exist in the npm artifacts, so every import resolves toany. That is environmental and predates this branch. I built withtsdown(which strips types without checking them) and ran the suite throughtsx. Please run a realtscagainst your monorepo checkout before merging — if it flags anything in the new files, tell me and I will fix it.