From 2818a9bd302a4783687bf34c809e96ded2c0c924 Mon Sep 17 00:00:00 2001 From: daveycodez Date: Tue, 25 Aug 2026 19:42:32 -0700 Subject: [PATCH] feat(usage): composer usage meter, plan tier, and Retry-After MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 6 +- src/auth/rpc.ts | 25 +- src/client/SubscriptionsSection.tsx | 24 +- src/client/UsageMeter.tsx | 513 ++++++++++++++++++++++++++++ src/client/index.ts | 23 ++ src/client/locales.ts | 18 + src/client/nav-icon.ts | 150 ++++++++ src/providers/claude.ts | 24 +- src/providers/common.ts | 38 ++- test/usage.spec.ts | 63 +++- 10 files changed, 868 insertions(+), 16 deletions(-) create mode 100644 src/client/UsageMeter.tsx create mode 100644 src/client/nav-icon.ts diff --git a/README.md b/README.md index 2b2a8ac..c740b20 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ The `video_generate` tool plays the generated clip inline: Only logged-in providers appear in the session model picker; the lists above refresh on login/logout. Vision-capable models declare `['text', 'image']` input modalities, and image content is translated to each provider's wire format. -Logged-in cards also show **subscription usage** — per rate-limit window (5-hour session, weekly, and per-model weekly where the plan has one) with the used percentage, a progress bar, and the reset time, plus a Refresh button. Codex usage comes from `chatgpt.com/backend-api/wham/usage` (also reports the plan), Claude usage from `api.anthropic.com/api/oauth/usage`, and Grok usage from the Grok Build CLI proxy's `cli-chat-proxy.grok.com/v1/billing` (the source of the CLI's `/usage` panel; reports the shared weekly pool and the subscription tier). Copilot exposes no usage endpoint, so its card shows no usage section. +Logged-in cards also show **subscription usage** — per rate-limit window (5-hour session, weekly, and per-model weekly where the plan has one) with the used percentage, a progress bar, and the reset time, plus a Refresh button. Codex usage comes from `chatgpt.com/backend-api/wham/usage` (also reports the plan), Claude usage from `api.anthropic.com/api/oauth/usage` (the plan comes from the stored subscription type, since that payload names no tier), and Grok usage from the Grok Build CLI proxy's `cli-chat-proxy.grok.com/v1/billing` (the source of the CLI's `/usage` panel; reports the shared weekly pool and the subscription tier). Copilot exposes no usage endpoint, so its card shows no usage section. + +Claude usage additionally rides a **meter in the composer**, left of the model selector and beside the shell's own context meter: a ring showing 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 — which turns amber at 75% and red at 95%. Hovering names the window and revalidates; clicking opens every reported limit with its reset time. It renders only while a Claude model is selected, so it never reports a limit the next turn will not spend. + +The meter is deliberately frugal with that endpoint, which is aggressively rate limited and shared with the settings page above: one cache serves every open session, an idle session issues no request at all, a running one asks every 3 minutes, and a refusal never clears the last good reading. A `Retry-After` on a 429 is honoured when the provider sends one. Also included, registered when the matching provider is enabled: diff --git a/src/auth/rpc.ts b/src/auth/rpc.ts index eb6794c..c243d71 100644 --- a/src/auth/rpc.ts +++ b/src/auth/rpc.ts @@ -11,7 +11,7 @@ import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { PROVIDER_IDS, type ProviderId } from './store.js' -import type { ProviderUsage } from '../providers/common.js' +import { OAuthEndpointError, type ProviderUsage } from '../providers/common.js' import type { ProxyConfigView, ProxyDraft, ProxyInput, ProxyTestResult } from '../http.js' /** The RPC channel this plugin registers on the host connection. */ @@ -137,7 +137,28 @@ function failure(error: unknown): RpcResult { // The issues array is zod-shaped upstream; this channel validates by hand. return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } } } - return { ok: false, error: { code: 'internal', message, details: {} } } + return { ok: false, error: { code: 'internal', message: withRetryAfter(error, message), details: {} } } +} + +/** + * Append the provider's retry delay to a failure message when it sent one. + * + * The delay belongs in structured data, but the `internal` RpcResult branch + * types `details` as an empty object upstream (`z.object({})` in + * dsh-host-apiproxy's rpc schema), so a field added here would be stripped in + * validation rather than reaching the page. The message is the only channel + * that survives, so the suffix is a stable, parseable one — a browser caller + * that backs off on a 429 can honour the interval the provider actually asked + * for instead of inventing its own. + * + * @param error - the thrown failure, which may carry a parsed `Retry-After`. + * @param message - the failure message as it stands. + * @returns the message, with ` (retry-after: s)` appended when known. + */ +function withRetryAfter(error: unknown, message: string): string { + if (!(error instanceof OAuthEndpointError) || error.retryAfterMs === undefined) return message + const seconds = Math.max(1, Math.round(error.retryAfterMs / 1000)) + return `${message} (retry-after: ${String(seconds)}s)` } function readProvider(payload: unknown): ProviderId { diff --git a/src/client/SubscriptionsSection.tsx b/src/client/SubscriptionsSection.tsx index f6f8229..652cbb6 100644 --- a/src/client/SubscriptionsSection.tsx +++ b/src/client/SubscriptionsSection.tsx @@ -193,11 +193,17 @@ const styles: Record = { display: 'flex', justifyContent: 'space-between', gap: 8, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)', }, + // Meter geometry follows the shell's own ContextMeter bar (4px, fully + // rounded, filled track, no border) so a progress bar contributed by a + // plugin reads as the same control the product draws elsewhere. usageTrack: { - height: 6, borderRadius: 3, overflow: 'hidden', - background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)', + height: 4, borderRadius: 999, overflow: 'hidden', + background: 'var(--dsw-alias-interactive-bg-hover)', + }, + usageFill: { + height: '100%', borderRadius: 999, + transition: 'width .3s ease, background-color .3s ease', }, - usageFill: { height: '100%', borderRadius: 3 }, manual: { marginTop: 4, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' }, manualRow: { display: 'flex', gap: 8, marginTop: 6 }, manualInput: { @@ -288,11 +294,17 @@ function usageWindowLabel(t: SubscriptionsSectionInjected['t'], window: UsageWin return window.scope !== undefined && window.scope !== '' ? `${base} · ${window.scope}` : base } -/** Bar fill color: success normally, warn from 80%, error from 95%. */ +/** + * Bar fill color. The steps follow the Claude Code app, which warns from 75% + * rather than 80%, so a plan this panel and that app both report reaches its + * warning shade at the same point. Below the warning step the meter is the + * neutral business tone rather than success green: a half-consumed limit is a + * reading, not an achievement, and green reads as the latter. + */ function usageBarColor(usedPercent: number): string { if (usedPercent >= 95) return 'var(--dsw-alias-state-error-primary)' - if (usedPercent >= 80) return 'var(--dsw-alias-state-warn-label)' - return 'var(--dsw-alias-state-success-primary)' + if (usedPercent >= 75) return 'var(--dsw-alias-state-warn-label)' + return 'var(--dsw-static-blue-450)' } /** One-line status text of the proxy config card. */ diff --git a/src/client/UsageMeter.tsx b/src/client/UsageMeter.tsx new file mode 100644 index 0000000..bbc6d4d --- /dev/null +++ b/src/client/UsageMeter.tsx @@ -0,0 +1,513 @@ +/** + * Composer usage meter: a progress ring in the input tool row + * (`conversation.input.right`) reporting the claude subscription limit that + * applies to the session's current model, with a click-open panel listing + * every limit the provider reported. + * + * It renders only while a claude model is selected, so it never shows a + * reading that does not describe the turn about to be sent. The ring geometry + * and the panel chrome follow the shell's own ContextMeter, which sits two + * seats to the right, so the pair reads as one family. + * + * Every color resolves through a `--dsw-*` design token and every user-visible + * string goes through the locale `t` of the 'settings.subscriptions' + * namespace, same as the settings section. + */ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { CSSProperties } from 'react' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { callSubscriptionsAuth } from './SubscriptionsSection.js' +import { en } from './locales.js' +import type { SubscriptionsKey } from './locales.js' +import type { ProviderUsage, UsageWindow } from '../providers/common.js' + +/** Ring geometry, matching the shell's ContextMeter: 14px box, 2px stroke. */ +const RADIUS = 5.5 +const CIRCUMFERENCE = 2 * Math.PI * RADIUS + +/** ContextMeter's own hover delay. */ +const TOOLTIP_DELAY_MS = 200 + +/** + * Model-gate cadence. The session's current model is not on the conversation + * snapshot and no model-change event exists, so the gate asks `sessions.models` + * — a local host RPC with no upstream traffic, cheap enough to poll briskly. + */ +const GATE_POLL_MS = 700 + +/** + * A model switch is always a user gesture, so any pointer or Enter gesture + * additionally probes on this staircase. The poll is the safety net; the burst + * is what makes the ring appear and disappear with the click rather than a + * tick later. + */ +const GATE_BURST_MS: readonly number[] = [90, 260, 600, 1100] +const GATE_BURST_THROTTLE_MS = 350 + +/** + * Usage revalidation. The upstream endpoint is aggressively rate limited and + * shared with this plugin's own settings page, so the meter is deliberately + * frugal: an idle session issues nothing at all, a running one asks on this + * interval, and hovering the ring revalidates under the same floor. + */ +const USAGE_MIN_INTERVAL_MS = 180_000 +const USAGE_RUNNING_INTERVAL_MS = 180_000 + +/** First rate-limit refusal stands down this long; later ones double to the cap. */ +const BACKOFF_START_MS = 300_000 +const BACKOFF_MAX_MS = 600_000 + +/** A request that never settles must not wedge the store. */ +const REQUEST_TIMEOUT_MS = 15_000 + +/** Warning step, matching the Claude Code app; the error step follows at 95%. */ +const WARN_PERCENT = 75 +const DANGER_PERCENT = 95 + +/** What the meter renders from: the last good reading and when it landed. */ +export interface UsageMeterState { + usage: ProviderUsage | null + at: number +} + +/** + * The shared usage cache. The slot renders once per session, so a fetcher per + * component would multiply the request rate against the shared endpoint by the + * number of open sessions; every instance subscribes to one store instead. + */ +export interface UsageMeterStore { + get: () => UsageMeterState + subscribe: (fn: (state: UsageMeterState) => void) => () => void + /** Revalidate if — and only if — the floor and any active backoff allow it. */ + request: () => void +} + +/** Whether the session's current model is a claude one, and which. */ +export interface ModelGate { + visible: boolean + model: string | null +} + +/** Injected dependencies of {@link UsageMeter} (slot `inject`, session-bound). */ +export interface UsageMeterInjected { + /** Resolve the session's current model; rejects rather than reporting "hidden". */ + checkModel: () => Promise + /** The shared usage store. */ + store: UsageMeterStore +} + +/** + * Props delivered by the slot outlet: the framework session kit and InputZone + * owner share, the injected face, and the locale seat. + */ +export type UsageMeterProps = PropsRuntime<'conversation.input.right'> + & Partial + & Partial> + +/** English-dictionary fallback for a missing inject `t` (standalone renders). */ +function fallbackTranslate(key: SubscriptionsKey, params?: Record): string { + let text: string = en[key] + for (const [name, value] of Object.entries(params ?? {})) { + text = text.replaceAll(`{${name}}`, String(value)) + } + return text +} + +/** Recognise a rate-limit refusal from the flattened RPC failure message. */ +function isRateLimited(message: string): boolean { + return /\b429\b/.test(message) || /rate.?limit/i.test(message) +} + +/** + * The delay the provider asked for, when the node half appended one. The + * `internal` RpcResult branch types `details` as an empty object upstream, so + * the retry hint rides the message text (see `withRetryAfter` in auth/rpc.ts). + */ +function retryHintMs(message: string): number | undefined { + const match = /retry-after:\s*(\d+)s/i.exec(message) + if (match === null) return undefined + const seconds = Number(match[1]) + return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined +} + +/** + * Build the shared usage store. + * + * Stale-while-revalidate throughout: the last successful reading is always + * what renders, and a failure — including a rate-limit refusal — never clears + * it. A refused request only schedules a later retry, so the meter degrades to + * slightly stale numbers rather than an empty or shouting panel. + * + * @param rpc - the connection's RPC caller. + * @returns the store; call `request()` to revalidate under the floor. + */ +export function createUsageStore(rpc: ConnectionHandle['rpc']): UsageMeterStore { + const listeners = new Set<(state: UsageMeterState) => void>() + let state: UsageMeterState = { usage: null, at: 0 } + let inflight = false + let blockedUntil = 0 + let backoffMs = 0 + + const publish = (next: UsageMeterState): void => { + state = next + for (const fn of [...listeners]) fn(state) + } + + const request = (): void => { + const now = Date.now() + if (inflight || now < blockedUntil || now - state.at < USAGE_MIN_INTERVAL_MS) return + inflight = true + let settled = false + const finish = (apply?: () => void): void => { + if (settled) return + settled = true + inflight = false + clearTimeout(watchdog) + apply?.() + } + const watchdog = setTimeout(() => { finish() }, REQUEST_TIMEOUT_MS) + void callSubscriptionsAuth(rpc, 'usage', { provider: 'claude' }).then( + (usage) => { + finish(() => { + backoffMs = 0 + blockedUntil = 0 + publish({ usage, at: Date.now() }) + }) + }, + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + finish(() => { + if (!isRateLimited(message)) return + const hint = retryHintMs(message) + backoffMs = hint ?? (backoffMs === 0 + ? BACKOFF_START_MS + : Math.min(backoffMs * 2, BACKOFF_MAX_MS)) + blockedUntil = Date.now() + backoffMs + }) + }, + ) + } + + return { + get: () => state, + subscribe(fn) { + listeners.add(fn) + return () => { listeners.delete(fn) } + }, + request, + } +} + +/** + * The `checkModel` half of the inject face: the host's current model selection. + * A failure throws rather than answering "hidden" — the caller keeps its last + * known state, so a transient RPC failure never blinks the meter away. + * + * `sessionId` is a plain string: slot and command contexts brand it through + * different dsh-session copies, and only the API-client boundary needs one. + */ +export function createModelChecker( + connection: ConnectionHandle, + sessionId: string, +): UsageMeterInjected['checkModel'] { + return async () => { + const { result } = await connection.api.sessions.models({ sessionId: sessionId as SessionId }) + if (!result.ok) throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`) + const current = result.value.current + if (current === null || current.provider !== 'claude') return { visible: false, model: null } + return { visible: true, model: current.model } + } +} + +/** + * The window the ring represents: on a model with its own weekly limit (Fable + * today) that limit, otherwise the shared weekly pool — the two readings a + * user is actually spending. Falls back to the most consumed window so the + * ring still means something on a plan shape this code has not seen. + */ +export function pickWindow(windows: readonly UsageWindow[], model: string | null): UsageWindow | null { + if (windows.length === 0) return null + const weekly = windows.filter(w => w.kind === 'weekly') + if (typeof model === 'string') { + const scoped = weekly.find(w => typeof w.scope === 'string' && w.scope.length > 0 + && model.toLowerCase().includes(w.scope.toLowerCase())) + if (scoped !== undefined) return scoped + } + const overall = weekly.find(w => w.scope === undefined || w.scope === '') + if (overall !== undefined) return overall + return weekly[0] ?? [...windows].sort((a, b) => b.usedPercent - a.usedPercent)[0] ?? null +} + +/** Clamp a reported percentage into the range the meter can draw. */ +function clamp(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +/** Ring and bar tint: neutral while healthy, warning from 75%, error from 95%. */ +function meterColor(percent: number): string { + if (percent >= DANGER_PERCENT) return 'var(--dsw-alias-state-error-primary)' + if (percent >= WARN_PERCENT) return 'var(--dsw-alias-state-warn-label)' + return 'var(--dsw-static-blue-450)' +} + +/** Localized label of one window: the kind, plus the model scope when named. */ +function windowLabel(t: NonNullable, window: UsageWindow): string { + const base = window.kind === 'session' + ? t('usageSession') + : window.kind === 'weekly' ? t('usageWeekly') : t('usageWindow') + const scope = window.scope !== undefined && window.scope !== '' ? window.scope : t('meterAllModels') + return window.kind === 'weekly' ? `${base} · ${scope}` : base +} + +/** + * Localized reset phrasing: a countdown while the window is close, and a + * weekday clock time beyond a day, where a countdown would be noise. + */ +function resetLabel( + t: NonNullable, + resetsAt: number | undefined, + now = Date.now(), +): string { + if (resetsAt === undefined) return '' + const diff = resetsAt - now + const HOUR = 3_600_000 + if (diff > 0 && diff < 24 * HOUR) { + const totalMinutes = Math.round(diff / 60_000) + const hours = Math.floor(totalMinutes / 60) + const minutes = totalMinutes % 60 + const duration = hours === 0 + ? t('meterMinutes', { count: minutes }) + : minutes === 0 + ? t('meterHours', { count: hours }) + : t('meterHoursMinutes', { hours, minutes }) + return t('meterResetsIn', { duration }) + } + const date = new Date(resetsAt) + return t('meterResetsAt', { + date: date.toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' }), + }) +} + +const styles = { + root: { display: 'inline-flex', position: 'relative' }, + trigger: { + width: 28, height: 28, padding: 0, border: 'none', borderRadius: 999, + background: 'transparent', cursor: 'pointer', flex: 'none', + display: 'grid', placeItems: 'center', + }, + track: { fill: 'none', stroke: 'var(--dsw-alias-border-l3)', strokeWidth: 2 }, + ring: { fill: 'none', strokeWidth: 2, strokeLinecap: 'round', transition: 'stroke-dasharray .3s ease, stroke .3s ease' }, + panel: { + position: 'absolute', right: 0, bottom: 'calc(100% + 8px)', zIndex: 100, + boxSizing: 'border-box', width: 320, maxWidth: 'calc(100vw - 32px)', padding: 12, + border: '1px solid var(--dsw-alias-border-inverted)', borderRadius: 12, + background: 'var(--dsw-specific-menu)', boxShadow: 'var(--dsw-shadow-lv3)', + color: 'var(--dsw-alias-label-secondary)', fontSize: 12, lineHeight: '20px', cursor: 'default', + }, + // Leading trim: a 12px glyph in a 20px line box carries 4px of half-leading, + // so without this the caption's ink sits 16px below the top edge while the + // last bar — a solid block with no leading — sits 12px above the bottom. + caption: { margin: '-4px 0 10px', color: 'var(--dsw-alias-label-tertiary)' }, + limit: { marginTop: 12 }, + line: { display: 'flex', alignItems: 'baseline', gap: 8 }, + name: { + minWidth: 0, color: 'var(--dsw-alias-label-primary)', fontWeight: 500, + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', + }, + reset: { marginLeft: 'auto', color: 'var(--dsw-alias-label-tertiary)', whiteSpace: 'nowrap', flex: 'none' }, + percent: { + color: 'var(--dsw-alias-label-primary)', fontWeight: 500, + fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', flex: 'none', + }, + bar: { + height: 4, marginTop: 6, borderRadius: 999, overflow: 'hidden', + background: 'var(--dsw-alias-interactive-bg-hover)', + }, + barFill: { height: '100%', borderRadius: 999, transition: 'width .3s ease, background-color .3s ease' }, + empty: { color: 'var(--dsw-alias-label-tertiary)' }, +} satisfies Record + +/** + * The composer meter. Renders nothing until the model gate proves a claude + * model is selected; from then on the ring tracks {@link pickWindow} and the + * panel lists every reported limit. + */ +export function UsageMeter({ checkModel, store, useSession, t }: UsageMeterProps) { + const translate = t ?? fallbackTranslate + const running = useSession?.(snapshot => snapshot.running) === true + const [gate, setGate] = useState({ visible: false, model: null }) + const [usageState, setUsageState] = useState(() => store?.get() ?? { usage: null, at: 0 }) + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + // The inject face may be re-evaluated on re-render; the effects mount once + // and read through refs, so identity churn neither resets the timers nor + // multiplies in-flight requests. + const checkRef = useRef(checkModel) + checkRef.current = checkModel + const storeRef = useRef(store) + storeRef.current = store + + useEffect(() => { + const current = storeRef.current + if (current === undefined) return + setUsageState(current.get()) + return current.subscribe(setUsageState) + }, []) + + useEffect(() => { + if (checkRef.current === undefined) return + let cancelled = false + let inflight = false + let lastBurst = 0 + const burstTimers: ReturnType[] = [] + + const check = (): void => { + const resolve = checkRef.current + if (cancelled || inflight || resolve === undefined) return + inflight = true + void resolve().then( + (next) => { + if (cancelled) return + setGate(prev => (prev.visible === next.visible && prev.model === next.model ? prev : next)) + }, + () => { /* keep the last known gate; the next tick retries */ }, + ).finally(() => { inflight = false }) + } + + const clearBurst = (): void => { + while (burstTimers.length > 0) clearTimeout(burstTimers.pop()) + } + + const burst = (event: Event): void => { + // Enter commits the /model popup; other keys are ordinary typing. + if (event.type === 'keyup' && (event as KeyboardEvent).key !== 'Enter') return + const now = Date.now() + if (now - lastBurst < GATE_BURST_THROTTLE_MS) return + lastBurst = now + clearBurst() + for (const delay of GATE_BURST_MS) burstTimers.push(setTimeout(check, delay)) + } + + check() + const poll = setInterval(check, GATE_POLL_MS) + document.addEventListener('pointerdown', burst, true) + document.addEventListener('keyup', burst, true) + return () => { + cancelled = true + clearBurst() + clearInterval(poll) + document.removeEventListener('pointerdown', burst, true) + document.removeEventListener('keyup', burst, true) + } + }, []) + + // Revalidate only while a turn is running; an idle session issues nothing, + // which is what keeps the shared endpoint available to the settings page. + useEffect(() => { + if (!gate.visible || !running) return + const timer = setInterval(() => { storeRef.current?.request() }, USAGE_RUNNING_INTERVAL_MS) + return () => { clearInterval(timer) } + }, [running, gate.visible]) + + useEffect(() => { + if (!open) return + const onPointerDown = (event: PointerEvent): void => { + if (event.target instanceof Node && rootRef.current?.contains(event.target) === true) return + setOpen(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [open]) + + useEffect(() => { + if (!gate.visible && open) setOpen(false) + }, [gate.visible, open]) + + const windows = usageState.usage?.windows ?? [] + const selected = pickWindow(windows, gate.model) + const percent = selected === null ? 0 : clamp(selected.usedPercent) + const color = meterColor(percent) + const label = selected === null + ? translate('usageTitle') + : `${windowLabel(translate, selected)} ${String(Math.round(percent))}%` + + /** + * Hovering is the revalidation trigger: pointing at the ring is the earliest + * honest signal of intent, so the panel opens onto an already-fresh reading + * instead of refetching underneath the user. The Tooltip resolves its label + * only while the bubble is visible, which makes it the natural hook. + */ + const resolveLabel = useCallback(() => { + storeRef.current?.request() + return label + }, [label]) + + if (!gate.visible) return null + + const plan = usageState.usage?.plan + const caption = plan === undefined || plan === '' + ? translate('meterCaption') + : translate('meterCaptionPlan', { plan }) + + return ( + + + + + {open && ( +
+

{caption}

+ {windows.length === 0 + ?
{translate('meterEmpty')}
+ : windows.map((window, index) => { + const used = clamp(window.usedPercent) + const reset = resetLabel(translate, window.resetsAt) + return ( +
+
+ {windowLabel(translate, window)} + {reset !== '' && {reset}} + + {`${String(Math.round(used))}%`} + +
+
+
+
+
+ ) + })} +
+ )} + + ) +} diff --git a/src/client/index.ts b/src/client/index.ts index 0437bac..e69810b 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -21,6 +21,9 @@ import type { CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/clie // nodenext the .js specifier resolves to the .tsx source (see README note). import { SubscriptionsSection } from './SubscriptionsSection.js' import type { SubscriptionsSectionInjected } from './SubscriptionsSection.js' +import { installNavIcon } from './nav-icon.js' +import { UsageMeter, createModelChecker, createUsageStore } from './UsageMeter.js' +import type { UsageMeterInjected } from './UsageMeter.js' import { ImageGenerateToolview, createImageLoader } from './ImageGenerateToolview.js' import type { ImageGenerateToolviewInjected } from './ImageGenerateToolview.js' import { VideoGenerateToolview, createVideoLoader } from './VideoGenerateToolview.js' @@ -85,6 +88,10 @@ export function apply(ctx: ClientContext): void { label: () => t('nav'), inject: injected, }, SubscriptionsSection)) + // The shell picks nav glyphs by section id and gives every unknown id the + // settings gear; registration carries no icon field. Decorate our own cell + // instead, reading the same label thunk so the match follows the locale. + ctx.effect(() => installNavIcon(() => t('nav')), 'dsh-plugin-subscriptions: settings nav glyph') // The image_generate keyed toolview owns how image calls render inline; its // gallery bytes ride the same channel through the injected loader. The @@ -121,6 +128,22 @@ export function apply(ctx: ClientContext): void { }), }, SpeedSelect)) + // The claude usage meter sits in the same tool row, just left of the model + // selector and two seats from the shell's own context meter it mirrors. One + // store serves every session: the slot renders per session, and the usage + // endpoint is both rate limited and shared with the settings page above. + const usageStore = createUsageStore(connection.rpc) + ctx.slots.inject('conversation.input.right', () => ctx.slots.register({ + name: 'conversation.input.right', + id: 'claude-usage', + order: 10, + locale: NS, + inject: (sessionId: SessionId): UsageMeterInjected => ({ + checkModel: createModelChecker(connection, sessionId), + store: usageStore, + }), + }, UsageMeter)) + // The /fast slash command offers the same Standard/Fast choice as a popup. // `available` is synchronous and sees only the session id, so the command // stays listed everywhere; `options` throws the friendly gate when the diff --git a/src/client/locales.ts b/src/client/locales.ts index 15bf826..7c9d220 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -34,6 +34,15 @@ export const en = { usageWindow: 'Window', usageResets: 'resets {date}', usagePlan: 'Plan: {plan}', + meterCaption: 'Plan usage limits', + meterCaptionPlan: 'Plan usage limits · {plan}', + meterAllModels: 'all models', + meterEmpty: 'No usage data yet.', + meterResetsIn: 'Resets in {duration}', + meterResetsAt: 'Resets {date}', + meterHours: '{count} hr', + meterHoursMinutes: '{hours} hr {minutes} min', + meterMinutes: '{count} min', generating: 'Generating image…', image: 'image', viewImage: 'View image', @@ -120,6 +129,15 @@ export const zh = { usageWindow: '窗口', usageResets: '{date} 重置', usagePlan: '计划:{plan}', + meterCaption: '订阅用量上限', + meterCaptionPlan: '订阅用量上限 · {plan}', + meterAllModels: '全部模型', + meterEmpty: '暂无用量数据。', + meterResetsIn: '{duration}后重置', + meterResetsAt: '{date} 重置', + meterHours: '{count} 小时', + meterHoursMinutes: '{hours} 小时 {minutes} 分', + meterMinutes: '{count} 分', generating: '正在生成图片…', image: '图片', viewImage: '查看图片', diff --git a/src/client/nav-icon.ts b/src/client/nav-icon.ts new file mode 100644 index 0000000..51b6a59 --- /dev/null +++ b/src/client/nav-icon.ts @@ -0,0 +1,150 @@ +/** + * Settings-nav glyph for the Subscriptions section. + * + * The settings shell picks nav icons by section id — `models`, + * `agent-presets`, and `plugins` get their own, and every other id falls back + * to the settings gear (see `navIcon` in dsh-client-ui-settings-general). + * `settings.section` registration carries only `id`, `order`, and `label`, so + * a registrant has no way to supply a glyph through the API. + * + * Until the shell offers one, this decorates our nav cell after render: it + * finds the button by the label WE registered and rewrites the icon in place. + * Three properties keep that honest: + * + * - it mutates the existing `` rather than replacing React-managed nodes, + * so React's tree is never invalidated underneath it; + * - it matches on the live value of our own `label` thunk, so it follows the + * active locale automatically instead of hardcoding translated strings; + * - every failure path leaves the shell's own gear in place, and disposal + * restores it, so the worst outcome is the icon we started with. + * + * If the shell ever accepts an icon in the registration, delete this file and + * pass the glyph instead. + */ + +/** A credit-card glyph, in the 24×24 stroked geometry the shell's icons use. */ +const CARD_ICON_INNER = '' + +/** Marks an svg this module already rewrote, so re-renders are cheap to skip. */ +const PATCHED_FLAG = 'subscriptionsNavIcon' + +/** What one patched icon needs to be restored to its shipped state. */ +interface Restore { + readonly svg: SVGElement + readonly viewBox: string | null + readonly fill: string | null + readonly stroke: string | null + readonly strokeWidth: string | null + readonly innerHTML: string +} + +function queryAll(root: ParentNode | Document, selector: string): Element[] { + try { + return typeof root.querySelectorAll === 'function' ? [...root.querySelectorAll(selector)] : [] + } catch { + return [] + } +} + +/** + * Rewrite the nav icon of every settings dialog whose row carries our label. + * @param label - the section label as currently rendered (the `label` thunk's value). + * @param restores - accumulator recording what each patch replaced. + */ +function patchNavIcons(label: string, restores: Restore[]): void { + if (typeof document === 'undefined' || document.body === null) return + if (label.length === 0) return + for (const dialog of queryAll(document, '[role="dialog"]')) { + for (const button of queryAll(dialog, 'nav button')) { + const text = typeof button.textContent === 'string' ? button.textContent : '' + if (!text.includes(label)) continue + const svg = button.querySelector('svg') + if (svg === null) continue + const flags = (svg as SVGElement & { dataset?: DOMStringMap }).dataset + if (flags === undefined || flags[PATCHED_FLAG] === '1') continue + try { + restores.push({ + svg, + viewBox: svg.getAttribute('viewBox'), + fill: svg.getAttribute('fill'), + stroke: svg.getAttribute('stroke'), + strokeWidth: svg.getAttribute('stroke-width'), + innerHTML: svg.innerHTML, + }) + flags[PATCHED_FLAG] = '1' + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('fill', 'none') + svg.setAttribute('stroke', 'currentColor') + svg.setAttribute('stroke-width', '1.5') + svg.setAttribute('stroke-linecap', 'round') + svg.setAttribute('stroke-linejoin', 'round') + svg.innerHTML = CARD_ICON_INNER + } catch { + // Any failure leaves the shell's own gear rendered; nothing to report. + } + } + } +} + +/** + * Start decorating our settings-nav cell. + * + * The panel mounts and unmounts with the dialog and re-renders on locale + * change, so the patch runs under a body observer rather than once: a fresh + * React icon arrives without our marker and is rewritten on the next tick. + * + * @param label - reads the section label as currently rendered; called per pass + * so a locale switch is picked up without re-registering anything. + * @returns a disposer that stops observing and restores every icon it changed. + */ +export function installNavIcon(label: () => string): () => void { + const restores: Restore[] = [] + const run = (): void => { + try { + patchNavIcons(label(), restores) + } catch { + // A failing label thunk must not break the observer. + } + } + + run() + + let observer: MutationObserver | undefined + try { + if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined' && document.body !== null) { + observer = new MutationObserver(run) + observer.observe(document.body, { childList: true, subtree: true }) + } + } catch { + observer = undefined + } + + return () => { + try { + observer?.disconnect() + } catch { + // Best-effort teardown. + } + for (const entry of restores) { + try { + const flags = (entry.svg as SVGElement & { dataset?: DOMStringMap }).dataset + if (flags !== undefined) delete flags[PATCHED_FLAG] + entry.innerHTML === '' ? entry.svg.replaceChildren() : (entry.svg.innerHTML = entry.innerHTML) + for (const [name, value] of [ + ['viewBox', entry.viewBox], + ['fill', entry.fill], + ['stroke', entry.stroke], + ['stroke-width', entry.strokeWidth], + ] as const) { + if (value === null) entry.svg.removeAttribute(name) + else entry.svg.setAttribute(name, value) + } + entry.svg.removeAttribute('stroke-linecap') + entry.svg.removeAttribute('stroke-linejoin') + } catch { + // The node may already be gone with the unmounted dialog. + } + } + restores.length = 0 + } +} diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 72b70aa..63c738d 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -285,11 +285,29 @@ function claudeLimitsWindows(value: unknown): UsageWindow[] { return windows } +/** + * Display name of the plan a stored `subscriptionType` names, e.g. `max` → + * `Max`. The usage endpoint reports no plan of its own (unlike codex, whose + * payload carries `plan_type`), so the session's own subscription type is the + * only tier this provider can disclose. An unknown value is title-cased + * rather than dropped, so a tier introduced later still shows up. + * @param subscriptionType - the value stored with the session, when present. + * @returns the display name, or undefined when the session names no tier. + */ +function claudePlanName(subscriptionType: string | undefined): string | undefined { + if (typeof subscriptionType !== 'string') return undefined + const trimmed = subscriptionType.trim() + if (trimmed.length === 0) return undefined + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1) +} + /** * Fetch the claude subscription usage from the OAuth usage endpoint (the * source of Claude Code's `/usage` screen). Newer responses carry a * structured `limits` array; older ones the flat `five_hour`/`seven_day*` * buckets — both shapes are read, the array winning when it has entries. + * The plan rides the stored session (see {@link claudePlanName}), because the + * payload itself names no tier. * @param session - the stored session (used as-is; never refreshed here). * @param fetchFn - fetch implementation (injectable for tests). * @param signal - caller cancellation from the RPC transport. @@ -313,8 +331,10 @@ export async function fetchClaudeUsage( }) if (!response.ok) throw await oauthEndpointError(response, 'claude usage') const payload = await response.json() as Record + const plan = claudePlanName(session.subscriptionType) + const planField = plan === undefined ? {} : { plan } const modern = claudeLimitsWindows(payload.limits) - if (modern.length > 0) return { supported: true, windows: modern } + if (modern.length > 0) return { supported: true, windows: modern, ...planField } const windows: UsageWindow[] = [] const legacy = [ claudeLegacyWindow(payload.five_hour, 'session'), @@ -325,7 +345,7 @@ export async function fetchClaudeUsage( for (const window of legacy) { if (window !== undefined) windows.push(window) } - return { supported: true, windows } + return { supported: true, windows, ...planField } } interface ClaudeModelCapabilities { diff --git a/src/providers/common.ts b/src/providers/common.ts index b8a2b6d..69a3c90 100644 --- a/src/providers/common.ts +++ b/src/providers/common.ts @@ -186,15 +186,44 @@ export class OAuthEndpointError extends Error { readonly status: number /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */ readonly oauthCode: string | undefined + /** + * How long the provider asked the caller to wait, in milliseconds, when it + * sent a `Retry-After` header. Rate-limited endpoints (notably the claude + * usage lookup) answer 429 with one, and a caller that ignores it can only + * guess at a backoff — so it rides the error rather than being dropped with + * the rest of the response. + */ + readonly retryAfterMs: number | undefined - constructor(message: string, status: number, oauthCode?: string) { + constructor(message: string, status: number, oauthCode?: string, retryAfterMs?: number) { super(message) this.name = 'OAuthEndpointError' this.status = status this.oauthCode = oauthCode + this.retryAfterMs = retryAfterMs } } +/** + * Parse a `Retry-After` header. The header carries either a delay in seconds + * or an HTTP date; both forms resolve to a forward-looking delay, and a past + * date or unparsable value answers undefined rather than a negative wait. + * @param value - the raw header value, when the response carried one. + * @param now - clock override for tests. + * @returns the delay in milliseconds, when the header named a future one. + */ +export function retryAfterMs(value: string | null, now: number = Date.now()): number | undefined { + if (value === null) return undefined + const trimmed = value.trim() + if (trimmed.length === 0) return undefined + const seconds = Number(trimmed) + if (Number.isFinite(seconds)) return seconds > 0 ? seconds * 1000 : undefined + const at = Date.parse(trimmed) + if (!Number.isFinite(at)) return undefined + const delay = at - now + return delay > 0 ? delay : undefined +} + /** * Read an OAuth JSON error body into an {@link OAuthEndpointError}. * @param response - the failed token-endpoint response. @@ -214,7 +243,12 @@ export async function oauthEndpointError(response: Response, label: string): Pro const message = detail.length > 0 ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}` : `${label} token endpoint error (HTTP ${String(response.status)})` - return new OAuthEndpointError(message, response.status, oauthCode) + return new OAuthEndpointError( + message, + response.status, + oauthCode, + retryAfterMs(response.headers.get('retry-after')), + ) } /** A session fresh enough to serve a request without a refresh. */ diff --git a/test/usage.spec.ts b/test/usage.spec.ts index 0541c99..fb445af 100644 --- a/test/usage.spec.ts +++ b/test/usage.spec.ts @@ -21,6 +21,7 @@ const { fetchClaudeUsage } = await import('../src/providers/claude.js') const { fetchGrokUsage, grokTierName } = await import('../src/providers/grok.js') const plugin = await import('../src/index.js') +import { OAuthEndpointError, retryAfterMs } from '../src/providers/common.js' import type { FetchFn } from '../src/providers/common.js' import type { ClaudeSession, CodexSession, GrokSession } from '../src/auth/store.js' @@ -43,8 +44,11 @@ const grokSession: GrokSession = { tokenEndpoint: 'https://auth.x.ai/token', } -/** A fetch implementation answering one JSON payload; records the request. */ -function fakeFetch(payload: unknown, status = 200): { +/** + * A fetch implementation answering one JSON payload; records the request. + * @param responseHeaders - headers to answer with (e.g. `retry-after` on a 429). + */ +function fakeFetch(payload: unknown, status = 200, responseHeaders?: Record): { fetchFn: FetchFn requests: { url: string; headers: Record }[] } { @@ -53,7 +57,10 @@ function fakeFetch(payload: unknown, status = 200): { const headers: Record = {} new Headers(init?.headers).forEach((value, key) => { headers[key] = value }) requests.push({ url: String(url), headers }) - return Promise.resolve(new Response(JSON.stringify(payload), { status })) + return Promise.resolve(new Response(JSON.stringify(payload), { + status, + ...responseHeaders === undefined ? {} : { headers: responseHeaders }, + })) }) as FetchFn return { fetchFn, requests } } @@ -181,6 +188,56 @@ test('fetchClaudeUsage prefers the modern limits array when present', async () = ]) }) +test('fetchClaudeUsage reports the plan from the stored subscription type', async () => { + // The usage payload names no tier (unlike codex's plan_type), so the plan + // rides the session; both response shapes must carry it. + const modern = fakeFetch({ + limits: [{ kind: 'weekly_all', percent: 40, resets_at: '2026-04-14T16:59:59Z' }], + }) + const withPlan = { ...claudeSession, subscriptionType: 'max' } + assert.equal((await fetchClaudeUsage(withPlan, modern.fetchFn)).plan, 'Max') + + const legacy = fakeFetch({ five_hour: { utilization: 6, resets_at: null } }) + assert.equal((await fetchClaudeUsage(withPlan, legacy.fetchFn)).plan, 'Max') +}) + +test('fetchClaudeUsage omits the plan when the session names none', async () => { + const { fetchFn } = fakeFetch({ + limits: [{ kind: 'weekly_all', percent: 40, resets_at: '2026-04-14T16:59:59Z' }], + }) + const usage = await fetchClaudeUsage(claudeSession, fetchFn) + assert.equal('plan' in usage, false) + const blank = await fetchClaudeUsage({ ...claudeSession, subscriptionType: ' ' }, fakeFetch({ + limits: [{ kind: 'weekly_all', percent: 40 }], + }).fetchFn) + assert.equal('plan' in blank, false) +}) + +test('retryAfterMs reads both header forms and refuses past deadlines', () => { + const now = 1_700_000_000_000 + assert.equal(retryAfterMs('120'), 120_000) + assert.equal(retryAfterMs('0'), undefined) + assert.equal(retryAfterMs(null), undefined) + assert.equal(retryAfterMs(' '), undefined) + assert.equal(retryAfterMs('tomorrow'), undefined) + assert.equal(retryAfterMs(new Date(now + 60_000).toUTCString(), now), 60_000) + assert.equal(retryAfterMs(new Date(now - 60_000).toUTCString(), now), undefined) +}) + +test('a rate-limited usage lookup surfaces the provider retry delay', async () => { + // The `internal` RpcResult branch types `details` as an empty object + // upstream, so the delay rides the message; a caller backing off on 429 can + // then honour the interval the provider actually asked for. + const { fetchFn } = fakeFetch({ error: 'rate_limited' }, 429, { 'retry-after': '300' }) + const error = await fetchClaudeUsage(claudeSession, fetchFn).then( + () => undefined, + (thrown: unknown) => thrown, + ) + assert.ok(error instanceof OAuthEndpointError) + assert.equal(error.status, 429) + assert.equal(error.retryAfterMs, 300_000) +}) + test('fetchGrokUsage maps the credits-config shape (weekly percent + reset)', async () => { const { fetchFn, requests } = fakeFetch({ config: {