dsh-subscriptions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/claude-code-creds.d.ts +23 -0
- package/lib/auth/claude-code-creds.js +172 -0
- package/lib/auth/device-flow.d.ts +55 -0
- package/lib/auth/device-flow.js +177 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +209 -0
- package/lib/auth/rpc.js +386 -0
- package/lib/auth/store.d.ts +196 -0
- package/lib/auth/store.js +468 -0
- package/lib/client/CursorCard.d.ts +9 -0
- package/lib/client/CursorCard.js +113 -0
- package/lib/client/ExternalUsageCards.d.ts +9 -0
- package/lib/client/ExternalUsageCards.js +72 -0
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +48 -0
- package/lib/client/ImageGenerateToolview.js +133 -0
- package/lib/client/ProviderAccountManager.d.ts +13 -0
- package/lib/client/ProviderAccountManager.js +147 -0
- package/lib/client/ProviderModelEditor.d.ts +39 -0
- package/lib/client/ProviderModelEditor.js +107 -0
- package/lib/client/SpeedSelect.d.ts +71 -0
- package/lib/client/SpeedSelect.js +177 -0
- package/lib/client/SubscriptionUsageBadge.d.ts +116 -0
- package/lib/client/SubscriptionUsageBadge.js +543 -0
- package/lib/client/SubscriptionsSection.d.ts +140 -0
- package/lib/client/SubscriptionsSection.js +544 -0
- package/lib/client/UsageBadgeDisplaySetting.d.ts +7 -0
- package/lib/client/UsageBadgeDisplaySetting.js +36 -0
- package/lib/client/VideoGenerateToolview.d.ts +42 -0
- package/lib/client/VideoGenerateToolview.js +175 -0
- package/lib/client/account-preferences.d.ts +24 -0
- package/lib/client/account-preferences.js +21 -0
- package/lib/client/fast-command.d.ts +2 -0
- package/lib/client/fast-command.js +4 -0
- package/lib/client/host-contract.d.ts +90 -0
- package/lib/client/host-contract.js +101 -0
- package/lib/client/host-icons.d.ts +24 -0
- package/lib/client/host-icons.js +21 -0
- package/lib/client/index.d.ts +27 -0
- package/lib/client/index.js +136 -0
- package/lib/client/locales.d.ts +297 -0
- package/lib/client/locales.js +295 -0
- package/lib/client/subscription-card-styles.d.ts +3 -0
- package/lib/client/subscription-card-styles.js +53 -0
- package/lib/client/subscriptions-rpc.d.ts +14 -0
- package/lib/client/subscriptions-rpc.js +34 -0
- package/lib/client/usage-badge-preferences.d.ts +11 -0
- package/lib/client/usage-badge-preferences.js +41 -0
- package/lib/client.js +4472 -0
- package/lib/client.js.map +1 -0
- package/lib/compat.d.ts +36 -0
- package/lib/compat.js +20 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +15 -0
- package/lib/index.d.ts +80 -0
- package/lib/index.js +14018 -0
- package/lib/model-defaults.d.ts +23 -0
- package/lib/model-defaults.js +237 -0
- package/lib/provider-settings.d.ts +39 -0
- package/lib/provider-settings.js +151 -0
- package/lib/providers/account-preferences.d.ts +41 -0
- package/lib/providers/account-preferences.js +153 -0
- package/lib/providers/accounts.d.ts +112 -0
- package/lib/providers/accounts.js +142 -0
- package/lib/providers/antigravity-oauth-client.d.ts +7 -0
- package/lib/providers/antigravity-oauth-client.js +7 -0
- package/lib/providers/antigravity.d.ts +110 -0
- package/lib/providers/antigravity.js +516 -0
- package/lib/providers/catalog-store.d.ts +48 -0
- package/lib/providers/catalog-store.js +243 -0
- package/lib/providers/claude-cli-version.d.ts +15 -0
- package/lib/providers/claude-cli-version.js +24 -0
- package/lib/providers/claude.d.ts +181 -0
- package/lib/providers/claude.js +680 -0
- package/lib/providers/codex-client-version.d.ts +8 -0
- package/lib/providers/codex-client-version.js +17 -0
- package/lib/providers/codex-search.d.ts +29 -0
- package/lib/providers/codex-search.js +198 -0
- package/lib/providers/codex.d.ts +229 -0
- package/lib/providers/codex.js +821 -0
- package/lib/providers/common.d.ts +389 -0
- package/lib/providers/common.js +656 -0
- package/lib/providers/copilot.d.ts +344 -0
- package/lib/providers/copilot.js +888 -0
- package/lib/providers/cursor-adapter.d.ts +15 -0
- package/lib/providers/cursor-adapter.js +39 -0
- package/lib/providers/cursor-auth.d.ts +43 -0
- package/lib/providers/cursor-auth.js +169 -0
- package/lib/providers/cursor-model-settings.d.ts +15 -0
- package/lib/providers/cursor-model-settings.js +51 -0
- package/lib/providers/cursor-usage.d.ts +9 -0
- package/lib/providers/cursor-usage.js +112 -0
- package/lib/providers/external-usage-controller.d.ts +19 -0
- package/lib/providers/external-usage-controller.js +32 -0
- package/lib/providers/external-usage.d.ts +8 -0
- package/lib/providers/external-usage.js +82 -0
- package/lib/providers/grok.d.ts +205 -0
- package/lib/providers/grok.js +713 -0
- package/lib/providers/image-pool.d.ts +30 -0
- package/lib/providers/image-pool.js +89 -0
- package/lib/providers/npm-cli-version.d.ts +56 -0
- package/lib/providers/npm-cli-version.js +98 -0
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +74 -0
- package/lib/providers/pool-health.js +150 -0
- package/lib/providers/pool-usage.d.ts +86 -0
- package/lib/providers/pool-usage.js +223 -0
- package/lib/providers/pool.d.ts +108 -0
- package/lib/providers/pool.js +368 -0
- package/lib/providers/rate-limit.d.ts +192 -0
- package/lib/providers/rate-limit.js +338 -0
- package/lib/tools/image-generate.d.ts +128 -0
- package/lib/tools/image-generate.js +438 -0
- package/lib/tools/registration.d.ts +15 -0
- package/lib/tools/registration.js +24 -0
- package/lib/tools/video-generate.d.ts +89 -0
- package/lib/tools/video-generate.js +257 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +197 -0
- package/lib/translate/anthropic.d.ts +161 -0
- package/lib/translate/anthropic.js +515 -0
- package/lib/translate/antigravity-schema.d.ts +4 -0
- package/lib/translate/antigravity-schema.js +145 -0
- package/lib/translate/antigravity-thinking.d.ts +5 -0
- package/lib/translate/antigravity-thinking.js +44 -0
- package/lib/translate/antigravity.d.ts +114 -0
- package/lib/translate/antigravity.js +366 -0
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +382 -0
- package/lib/translate/resolved.d.ts +80 -0
- package/lib/translate/resolved.js +118 -0
- package/lib/translate/responses.d.ts +179 -0
- package/lib/translate/responses.js +408 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +114 -0
- package/vendor/cursor/LICENSE +21 -0
- package/vendor/cursor/index.d.ts +18 -0
- package/vendor/cursor/index.js +3574 -0
- package/vendor/cursor/proto.js +254 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Subscription usage badge: a stats pill in the composer's dock
|
|
4
|
+
* (`conversation.composer.dock`), modelled on the host's own token-usage
|
|
5
|
+
* pill. Collapsed, it shows the rate-limit windows of the provider behind the
|
|
6
|
+
* session's CURRENT model (a GPT model → Codex usage, a Claude model → Claude
|
|
7
|
+
* usage); clicking it opens a trigger-anchored dialog listing every logged-in
|
|
8
|
+
* account of every provider, with long window lists collapsed into previews,
|
|
9
|
+
* the current provider first and the default account (starred) first within
|
|
10
|
+
* a provider. Model-scoped Antigravity usage follows the current model.
|
|
11
|
+
*
|
|
12
|
+
* Usage rides the `subscriptions-auth` usage endpoints on a slow
|
|
13
|
+
* poll (the server shares its cache across UI surfaces); the current model
|
|
14
|
+
* comes from ui-model-selection's `modelDirectories` service on a quicker
|
|
15
|
+
* poll, since the host pushes nothing on a model switch. Renders nothing when
|
|
16
|
+
* no connected provider reports usage.
|
|
17
|
+
*
|
|
18
|
+
* The collapsed pill reads only the default account — the same account
|
|
19
|
+
* direct (non-pool) routes serve — so it stays one short segment even for a
|
|
20
|
+
* provider with several accounts connected; the dialog shows them all.
|
|
21
|
+
* Every color resolves through a `--dsw-*` design token and every
|
|
22
|
+
* user-visible string goes through the locale `t` of the
|
|
23
|
+
* 'settings.subscriptions' namespace.
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
26
|
+
import { createPortal } from 'react-dom';
|
|
27
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives';
|
|
28
|
+
import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
29
|
+
import { callSubscriptionsAuth, usageBarColor } from './SubscriptionsSection.js';
|
|
30
|
+
import { en } from './locales.js';
|
|
31
|
+
import { hostIcon } from './host-icons.js';
|
|
32
|
+
import { USAGE_BADGE_REFRESH_EVENT, useUsageBadgeMode } from './usage-badge-preferences.js';
|
|
33
|
+
/** DSH renamed the data icon in 0.1.7; retain older supported hosts too. */
|
|
34
|
+
export function usageBadgeIcon(icons) {
|
|
35
|
+
return hostIcon(icons, 'DataOutline');
|
|
36
|
+
}
|
|
37
|
+
const UsageBadgeIcon = usageBadgeIcon(primitives);
|
|
38
|
+
/** How often the badge re-reads usage; the server also shares its own cache/negative-cache across UI surfaces. */
|
|
39
|
+
const USAGE_POLL_INTERVAL_MS = 15 * 60_000;
|
|
40
|
+
/** How often the badge re-reads the session's current model (model switches arrive only by asking). */
|
|
41
|
+
const MODEL_POLL_INTERVAL_MS = 3000;
|
|
42
|
+
/** Distance between the trigger's top edge and the dialog's bottom (host stat dialogs use the same). */
|
|
43
|
+
const PANEL_GAP = 8;
|
|
44
|
+
/** Distance kept between the dialog and each viewport edge. */
|
|
45
|
+
const PANEL_MARGIN = 12;
|
|
46
|
+
/** The account the collapsed pill reads: the default one, else the first listed. */
|
|
47
|
+
export function pillAccountOf(d) {
|
|
48
|
+
return d.accounts.find(a => a.isDefault) ?? d.accounts[0];
|
|
49
|
+
}
|
|
50
|
+
const PROVIDER_NAMES = {
|
|
51
|
+
codex: 'Codex',
|
|
52
|
+
claude: 'Claude',
|
|
53
|
+
grok: 'Grok',
|
|
54
|
+
copilot: 'Copilot',
|
|
55
|
+
antigravity: 'Antigravity',
|
|
56
|
+
'cursor-subscription': 'Cursor',
|
|
57
|
+
'opencode-go': 'OpenCode Go',
|
|
58
|
+
'kimi-coding': 'Kimi Code',
|
|
59
|
+
};
|
|
60
|
+
/** Key of one account's cached windows: `provider:accountKey`. */
|
|
61
|
+
function usageKeyOf(provider, account) {
|
|
62
|
+
return `${provider}:${account.key}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Group per-account usage into the badge's provider rows, in roster order
|
|
66
|
+
* (the `status` provider order, default account first) so rows don't jump
|
|
67
|
+
* around as polls settle at different times. An account without windows is
|
|
68
|
+
* left out, and so is a provider whose accounts all lack windows.
|
|
69
|
+
* @param roster - accounts from {@link loadBadgeRoster}.
|
|
70
|
+
* @param windowsOf - each account's windows, keyed `provider:accountKey`.
|
|
71
|
+
* @param plans - the plan each usage call reported, same keys; the roster's plan is the fallback.
|
|
72
|
+
* @returns one display per provider with at least one account that has windows.
|
|
73
|
+
*/
|
|
74
|
+
export function groupUsageDisplays(roster, windowsOf, plans) {
|
|
75
|
+
const byProvider = new Map();
|
|
76
|
+
for (const { provider, account } of roster) {
|
|
77
|
+
const key = usageKeyOf(provider, account);
|
|
78
|
+
const windows = windowsOf.get(key);
|
|
79
|
+
if (windows === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
const plan = plans.get(key) ?? account.plan;
|
|
82
|
+
const row = {
|
|
83
|
+
key: account.key,
|
|
84
|
+
isDefault: account.isDefault,
|
|
85
|
+
...account.account === undefined ? {} : { account: account.account },
|
|
86
|
+
...plan === undefined ? {} : { plan },
|
|
87
|
+
windows,
|
|
88
|
+
};
|
|
89
|
+
const display = byProvider.get(provider);
|
|
90
|
+
if (display === undefined)
|
|
91
|
+
byProvider.set(provider, { provider, name: PROVIDER_NAMES[provider], accounts: [row] });
|
|
92
|
+
else
|
|
93
|
+
display.accounts.push(row);
|
|
94
|
+
}
|
|
95
|
+
return [...byProvider.values()];
|
|
96
|
+
}
|
|
97
|
+
/** Read each account source independently so a failing endpoint cannot hide the others. */
|
|
98
|
+
export async function loadBadgeRoster(rpc) {
|
|
99
|
+
const [subscriptions, cursor, external] = await Promise.allSettled([
|
|
100
|
+
callSubscriptionsAuth(rpc, 'status', {}),
|
|
101
|
+
callSubscriptionsAuth(rpc, 'cursorStatus', {}),
|
|
102
|
+
callSubscriptionsAuth(rpc, 'externalStatus', {}),
|
|
103
|
+
]);
|
|
104
|
+
const roster = [];
|
|
105
|
+
const refreshed = new Set();
|
|
106
|
+
if (subscriptions.status === 'fulfilled') {
|
|
107
|
+
for (const provider of ['codex', 'claude', 'grok', 'copilot', 'antigravity']) {
|
|
108
|
+
refreshed.add(provider);
|
|
109
|
+
for (const account of accountsOf(subscriptions.value.providers[provider]))
|
|
110
|
+
roster.push({ provider, account });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (cursor.status === 'fulfilled') {
|
|
114
|
+
refreshed.add('cursor-subscription');
|
|
115
|
+
if (cursor.value.authenticated)
|
|
116
|
+
roster.push({
|
|
117
|
+
provider: 'cursor-subscription', account: { key: 'cursor', isDefault: true },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (external.status === 'fulfilled') {
|
|
121
|
+
for (const [source, provider] of [
|
|
122
|
+
['opencode-go', 'opencode-go'], ['kimi-code', 'kimi-coding'],
|
|
123
|
+
]) {
|
|
124
|
+
refreshed.add(provider);
|
|
125
|
+
if (external.value[source]?.configured)
|
|
126
|
+
roster.push({
|
|
127
|
+
provider, account: { key: source, isDefault: true },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { roster, refreshed };
|
|
132
|
+
}
|
|
133
|
+
export async function usageOf(rpc, { provider, account }) {
|
|
134
|
+
if (provider === 'cursor-subscription')
|
|
135
|
+
return callSubscriptionsAuth(rpc, 'cursorUsage', {});
|
|
136
|
+
if (provider === 'opencode-go' || provider === 'kimi-coding') {
|
|
137
|
+
return callSubscriptionsAuth(rpc, 'externalUsage', { source: provider === 'kimi-coding' ? 'kimi-code' : provider });
|
|
138
|
+
}
|
|
139
|
+
return callSubscriptionsAuth(rpc, 'usage', { provider, account: account.key });
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The `currentModel` half of the inject face: the session's effective
|
|
143
|
+
* model selection through ui-model-selection's `modelDirectories` service,
|
|
144
|
+
* resolved lazily per call (the service may register after this plugin, and
|
|
145
|
+
* a shell without it reports "unknown"; the badge keeps the most recent
|
|
146
|
+
* subscription selected in this view, or stays hidden).
|
|
147
|
+
*/
|
|
148
|
+
export function createCurrentModelReader(models, sessionId) {
|
|
149
|
+
return async () => {
|
|
150
|
+
const directories = models();
|
|
151
|
+
if (directories === undefined)
|
|
152
|
+
return undefined;
|
|
153
|
+
const { current } = await directories.directoryFor(sessionId).load();
|
|
154
|
+
return current ?? undefined;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Compact time-remaining label derived from the window's `resetsAt` timestamp:
|
|
159
|
+
* "6d18h" (days+hours), "1h58m" (hours+minutes), or "42m" (minutes only).
|
|
160
|
+
* Falls back to the scope/kind abbreviation when no reset time is known.
|
|
161
|
+
*/
|
|
162
|
+
export function windowLabel(w) {
|
|
163
|
+
if (w.resetsAt === undefined) {
|
|
164
|
+
if (w.scope !== undefined && w.scope !== '')
|
|
165
|
+
return w.scope;
|
|
166
|
+
switch (w.kind) {
|
|
167
|
+
case 'session': return '5h';
|
|
168
|
+
case 'weekly': return 'Wk';
|
|
169
|
+
default: return 'W';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const ms = Math.max(0, w.resetsAt - Date.now());
|
|
173
|
+
const minutes = Math.floor(ms / 60_000);
|
|
174
|
+
const hours = Math.floor(minutes / 60);
|
|
175
|
+
const days = Math.floor(hours / 24);
|
|
176
|
+
if (days > 0)
|
|
177
|
+
return `${days}d${hours % 24}h`;
|
|
178
|
+
if (hours > 0)
|
|
179
|
+
return `${hours}h${minutes % 60}m`;
|
|
180
|
+
return `${Math.max(1, minutes)}m`;
|
|
181
|
+
}
|
|
182
|
+
/** Clamp and round a window's used share for display. */
|
|
183
|
+
function usedPercent(w) {
|
|
184
|
+
return Math.round(Math.min(100, Math.max(0, w.usedPercent)));
|
|
185
|
+
}
|
|
186
|
+
/** Keep model quotas separate: matching percentages do not imply a shared pool. */
|
|
187
|
+
export function prioritizeWindows(windows, model) {
|
|
188
|
+
return model === undefined ? [...windows] : [
|
|
189
|
+
...windows.filter(w => w.scope === model),
|
|
190
|
+
...windows.filter(w => w.scope !== model),
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
/** Small previews keep a live model catalog from taking over the dialog. */
|
|
194
|
+
export const WINDOW_PREVIEW_LIMIT = 4;
|
|
195
|
+
export function previewWindows(windows, model, provider) {
|
|
196
|
+
const ordered = prioritizeWindows(windows, model);
|
|
197
|
+
// Antigravity has many model-specific quotas: preview only the current model.
|
|
198
|
+
const limit = provider === 'antigravity'
|
|
199
|
+
? Math.min(2, model === undefined ? 0 : windows.filter(w => w.scope === model).length)
|
|
200
|
+
: WINDOW_PREVIEW_LIMIT;
|
|
201
|
+
return { shown: ordered.slice(0, limit), hidden: ordered.slice(limit) };
|
|
202
|
+
}
|
|
203
|
+
/** Bounded readout; Antigravity quotas belong to individual models, not the account. */
|
|
204
|
+
export function compactSegment(d, model, t = fallbackTranslate) {
|
|
205
|
+
const windows = pillAccountOf(d).windows;
|
|
206
|
+
if (d.provider === 'antigravity') {
|
|
207
|
+
const matching = model === undefined ? [] : windows.filter(w => w.scope === model);
|
|
208
|
+
if (matching.length === 0) {
|
|
209
|
+
return `${d.name} ${t(model === undefined ? 'usageBadgeModelCount' : 'usageBadgeModelUnavailable', {
|
|
210
|
+
count: new Set(windows.map(w => w.scope).filter(Boolean)).size,
|
|
211
|
+
})}`;
|
|
212
|
+
}
|
|
213
|
+
const parts = matching.slice(0, 2).map(w => `${w.kind === 'weekly' ? t('usageWeekly') : t('usageWindow')} ${usedPercent(w)}%`);
|
|
214
|
+
return `${d.name} ${parts.join(' · ')}`;
|
|
215
|
+
}
|
|
216
|
+
const parts = windows.slice(0, 2).map(w => `${windowLabel(w)} ${usedPercent(w)}%`);
|
|
217
|
+
if (windows.length > 2)
|
|
218
|
+
parts.push(`+${windows.length - 2}`);
|
|
219
|
+
return `${d.name} ${parts.join(' · ')}`;
|
|
220
|
+
}
|
|
221
|
+
/** Remember only the most recent subscription in this view, including its model scope. */
|
|
222
|
+
export function retainSubscriptionSelection(previous, current) {
|
|
223
|
+
return current !== undefined && Object.hasOwn(PROVIDER_NAMES, current.provider) ? current : previous;
|
|
224
|
+
}
|
|
225
|
+
/** At most one provider; no known selection or no usage means no badge. */
|
|
226
|
+
export function collapsedDisplays(displays, current) {
|
|
227
|
+
const match = displays.find(d => d.provider === current);
|
|
228
|
+
return match === undefined ? [] : [match];
|
|
229
|
+
}
|
|
230
|
+
/** Order for the expanded dialog: the current provider first, the rest in poll order. */
|
|
231
|
+
export function expandedDisplays(displays, current) {
|
|
232
|
+
const match = displays.find(d => d.provider === current);
|
|
233
|
+
return match === undefined ? displays : [match, ...displays.filter(d => d !== match)];
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* A provider's logged-in accounts, the effective default first. When no
|
|
237
|
+
* account is flagged default the first listed stands in, matching what
|
|
238
|
+
* direct routes fall back to.
|
|
239
|
+
*/
|
|
240
|
+
function accountsOf(status) {
|
|
241
|
+
if (status === undefined || status.accounts.length === 0)
|
|
242
|
+
return [];
|
|
243
|
+
const fallback = status.accounts.find(a => a.isDefault) ?? status.accounts[0];
|
|
244
|
+
return status.accounts
|
|
245
|
+
.map(a => (a === fallback ? { ...a, isDefault: true } : a))
|
|
246
|
+
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
|
247
|
+
}
|
|
248
|
+
/** English-dictionary fallback for a missing inject `t` (standalone renders). */
|
|
249
|
+
function fallbackTranslate(key, params) {
|
|
250
|
+
return en[key].replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? ''));
|
|
251
|
+
}
|
|
252
|
+
/** Localized label of one usage window (kind, plus the model scope when named). */
|
|
253
|
+
function usageWindowLabel(t, window) {
|
|
254
|
+
const base = window.kind === 'session'
|
|
255
|
+
? t('usageSession')
|
|
256
|
+
: window.kind === 'weekly' ? t('usageWeekly') : t('usageWindow');
|
|
257
|
+
return window.scope !== undefined && window.scope !== '' ? `${base} · ${window.scope}` : base;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The composer subscription-usage badge: a pill reading e.g.
|
|
261
|
+
* `Codex 6d1h 25%` for the current model's provider, opening a dialog with
|
|
262
|
+
* every provider's accounts and their windows. Returns null when no data is
|
|
263
|
+
* available.
|
|
264
|
+
*/
|
|
265
|
+
export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
|
|
266
|
+
const translate = t ?? fallbackTranslate;
|
|
267
|
+
const [displays, setDisplays] = useState([]);
|
|
268
|
+
const [selection, setSelection] = useState(undefined);
|
|
269
|
+
const [lastSubscription, setLastSubscription] = useState(undefined);
|
|
270
|
+
const displayMode = useUsageBadgeMode();
|
|
271
|
+
const current = selection?.provider;
|
|
272
|
+
const badgeSelection = retainSubscriptionSelection(lastSubscription, selection);
|
|
273
|
+
const [open, setOpen] = useState(false);
|
|
274
|
+
const [hover, setHover] = useState(false);
|
|
275
|
+
const inflightRef = useRef(false);
|
|
276
|
+
const mountedRef = useRef(true);
|
|
277
|
+
const rootRef = useRef(null);
|
|
278
|
+
const panelRef = useRef(null);
|
|
279
|
+
// Always-rendered, invisible marker in the dock: locates the composer bar
|
|
280
|
+
// (and the host stats row inside it) even while the pill itself is portaled.
|
|
281
|
+
const seatRef = useRef(null);
|
|
282
|
+
// The inject face may be re-evaluated (new callback identities) on
|
|
283
|
+
// re-render; the model poll mounts once and reads through this ref.
|
|
284
|
+
const currentRef = useRef(currentModel);
|
|
285
|
+
currentRef.current = currentModel;
|
|
286
|
+
// Last-known-good windows per account (keyed `provider:accountKey`), kept
|
|
287
|
+
// across a failed poll (e.g. a 429 during the server's own negative-cache
|
|
288
|
+
// cooldown) so a row doesn't flicker away — it only disappears once the
|
|
289
|
+
// account actually logs out or a fetch succeeds but reports the window as
|
|
290
|
+
// unsupported.
|
|
291
|
+
const lastKnownRef = useRef(new Map());
|
|
292
|
+
const refresh = useCallback(async () => {
|
|
293
|
+
if (rpc === undefined || inflightRef.current)
|
|
294
|
+
return;
|
|
295
|
+
inflightRef.current = true;
|
|
296
|
+
try {
|
|
297
|
+
const { roster, refreshed } = await loadBadgeRoster(rpc);
|
|
298
|
+
if (!mountedRef.current)
|
|
299
|
+
return;
|
|
300
|
+
const lastKnown = lastKnownRef.current;
|
|
301
|
+
// Drop last-known state for anything no longer logged in — that is a
|
|
302
|
+
// real signal, unlike a fetch failure.
|
|
303
|
+
const live = new Set(roster.map(({ provider, account }) => usageKeyOf(provider, account)));
|
|
304
|
+
for (const key of lastKnown.keys()) {
|
|
305
|
+
if (refreshed.has(key.split(':', 1)[0]) && !live.has(key))
|
|
306
|
+
lastKnown.delete(key);
|
|
307
|
+
}
|
|
308
|
+
const results = await Promise.allSettled(roster.map(async ({ provider, account }) => {
|
|
309
|
+
const usage = await usageOf(rpc, { provider, account });
|
|
310
|
+
return { provider, account, usage };
|
|
311
|
+
}));
|
|
312
|
+
if (!mountedRef.current)
|
|
313
|
+
return;
|
|
314
|
+
const plans = new Map();
|
|
315
|
+
for (const r of results) {
|
|
316
|
+
if (r.status !== 'fulfilled')
|
|
317
|
+
continue; // keep whatever is cached for this account
|
|
318
|
+
const { provider, account, usage } = r.value;
|
|
319
|
+
const key = usageKeyOf(provider, account);
|
|
320
|
+
if (usage.plan !== undefined)
|
|
321
|
+
plans.set(key, usage.plan);
|
|
322
|
+
if (!usage.supported || !usage.windows || usage.windows.length === 0) {
|
|
323
|
+
lastKnown.delete(key);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
lastKnown.set(key, usage.windows);
|
|
327
|
+
}
|
|
328
|
+
setDisplays(previous => [
|
|
329
|
+
...groupUsageDisplays(roster, lastKnown, plans),
|
|
330
|
+
...previous.filter(display => !refreshed.has(display.provider)),
|
|
331
|
+
]);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// A failed poll must not crash the badge; keep last known state.
|
|
335
|
+
}
|
|
336
|
+
finally {
|
|
337
|
+
inflightRef.current = false;
|
|
338
|
+
}
|
|
339
|
+
}, [rpc]);
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
mountedRef.current = true;
|
|
342
|
+
return () => { mountedRef.current = false; };
|
|
343
|
+
}, []);
|
|
344
|
+
// Hidden renders nothing, so it polls nothing; showing it again refreshes at once.
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
if (displayMode === 'hidden')
|
|
347
|
+
return;
|
|
348
|
+
void refresh();
|
|
349
|
+
const timer = setInterval(() => { void refresh(); }, USAGE_POLL_INTERVAL_MS);
|
|
350
|
+
window.addEventListener(USAGE_BADGE_REFRESH_EVENT, refresh);
|
|
351
|
+
return () => {
|
|
352
|
+
clearInterval(timer);
|
|
353
|
+
window.removeEventListener(USAGE_BADGE_REFRESH_EVENT, refresh);
|
|
354
|
+
};
|
|
355
|
+
}, [refresh, displayMode]);
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (currentRef.current === undefined)
|
|
358
|
+
return;
|
|
359
|
+
let cancelled = false;
|
|
360
|
+
let inflight = false;
|
|
361
|
+
const reload = () => {
|
|
362
|
+
const read = currentRef.current;
|
|
363
|
+
if (read === undefined || inflight)
|
|
364
|
+
return;
|
|
365
|
+
inflight = true;
|
|
366
|
+
void read().then((model) => {
|
|
367
|
+
if (cancelled)
|
|
368
|
+
return;
|
|
369
|
+
setLastSubscription(previous => retainSubscriptionSelection(previous, model));
|
|
370
|
+
setSelection(model);
|
|
371
|
+
}, () => { }).finally(() => { inflight = false; });
|
|
372
|
+
};
|
|
373
|
+
reload();
|
|
374
|
+
const timer = setInterval(reload, MODEL_POLL_INTERVAL_MS);
|
|
375
|
+
return () => {
|
|
376
|
+
cancelled = true;
|
|
377
|
+
clearInterval(timer);
|
|
378
|
+
};
|
|
379
|
+
}, []);
|
|
380
|
+
const pos = useAnchoredPosition({ open, anchorRef: rootRef, panelRef, side: 'top', gap: PANEL_GAP, margin: PANEL_MARGIN });
|
|
381
|
+
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef);
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (!open)
|
|
384
|
+
return;
|
|
385
|
+
const onKeyDown = (event) => {
|
|
386
|
+
if (event.key === 'Escape')
|
|
387
|
+
setOpen(false);
|
|
388
|
+
};
|
|
389
|
+
document.addEventListener('keydown', onKeyDown);
|
|
390
|
+
return () => { document.removeEventListener('keydown', onKeyDown); };
|
|
391
|
+
}, [open]);
|
|
392
|
+
// Sit on the host's stats row when there is one. Every dock entry is its
|
|
393
|
+
// own row in the composer bar, so a badge rendered in place lands under the
|
|
394
|
+
// shipped time/token pills; the host marks its pill row with
|
|
395
|
+
// `data-composer-stats`, and rendering into it makes the badge a third pill
|
|
396
|
+
// on that line. The marker is watched (the row mounts only once the session
|
|
397
|
+
// has steps or tokens, and unmounts with them) and older hosts without it
|
|
398
|
+
// keep the in-place row.
|
|
399
|
+
const [statsRow, setStatsRow] = useState(null);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
const seat = seatRef.current;
|
|
402
|
+
if (seat === null)
|
|
403
|
+
return;
|
|
404
|
+
const scope = statsScopeOf(seat);
|
|
405
|
+
if (scope === null)
|
|
406
|
+
return;
|
|
407
|
+
const find = () => scope.querySelector('[data-composer-stats]');
|
|
408
|
+
setStatsRow(find());
|
|
409
|
+
const observer = new MutationObserver(() => { setStatsRow(find()); });
|
|
410
|
+
observer.observe(scope, { childList: true, subtree: true });
|
|
411
|
+
return () => { observer.disconnect(); };
|
|
412
|
+
}, []);
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (displayMode === 'hidden')
|
|
415
|
+
setOpen(false);
|
|
416
|
+
}, [displayMode]);
|
|
417
|
+
const seat = _jsx("span", { ref: seatRef, style: styles.seat, "aria-hidden": true });
|
|
418
|
+
const collapsed = collapsedDisplays(displays, badgeSelection?.provider);
|
|
419
|
+
if (displayMode === 'hidden' || collapsed.length === 0)
|
|
420
|
+
return seat;
|
|
421
|
+
const label = compactSegment(collapsed[0], badgeSelection?.model, translate);
|
|
422
|
+
// The dialog opens from the badge, so it follows the provider and model the
|
|
423
|
+
// badge shows (a retained subscription when the current model is not one).
|
|
424
|
+
const expanded = expandedDisplays(displays, badgeSelection?.provider);
|
|
425
|
+
const title = translate('usageBadgeTitle');
|
|
426
|
+
const toggle = () => {
|
|
427
|
+
const next = !open;
|
|
428
|
+
setOpen(next);
|
|
429
|
+
if (next)
|
|
430
|
+
void refresh();
|
|
431
|
+
};
|
|
432
|
+
const pill = (_jsxs("span", { ref: rootRef, style: styles.anchor, children: [_jsxs("button", { type: "button", style: { ...styles.pill, ...(hover || open ? styles.pillActive : {}) }, "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": `${title} · ${label}`, title: title, onMouseEnter: () => { setHover(true); }, onMouseLeave: () => { setHover(false); }, onClick: toggle, children: [_jsx(UsageBadgeIcon, {}), _jsx("span", { style: styles.label, children: label })] }), open && createPortal(_jsxs("div", { ref: panelRef, role: "dialog", "aria-label": title, style: { ...styles.panel, ...(pos ?? MEASURE_STYLE) }, children: [_jsx("div", { style: styles.title, children: _jsxs("span", { style: styles.titleLabel, children: [_jsx(UsageBadgeIcon, {}), title] }) }), _jsx("div", { style: styles.titleRule, "aria-hidden": true }), expanded.map((d, index) => (_jsxs("section", { style: index === 0 ? undefined : styles.section, children: [_jsxs("div", { style: styles.providerRow, children: [_jsxs("span", { style: styles.providerName, children: [d.name, d.provider === current && _jsx("span", { style: styles.currentTag, children: translate('usageBadgeCurrent') })] }), d.accounts.length === 1 && _jsx(AccountMeta, { account: d.accounts[0], translate: translate })] }), d.accounts.map((account, accountIndex) => (_jsxs("div", { style: accountIndex === 0 ? undefined : styles.accountBlock, children: [d.accounts.length > 1 && (_jsx("div", { style: styles.accountRow, children: _jsx(AccountMeta, { account: account, translate: translate }) })), _jsx(AccountWindows, { windows: account.windows, model: d.provider === badgeSelection?.provider ? badgeSelection.model : undefined, provider: d.provider, translate: translate }, `${d.provider}:${badgeSelection?.model ?? ''}`)] }, account.key)))] }, d.provider)))] }), document.body)] }));
|
|
433
|
+
return (_jsxs(_Fragment, { children: [seat, statsRow !== null && statsRow.isConnected ? createPortal(pill, statsRow) : pill] }));
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Nearest ancestor of the dock seat that can contain the host's stats row:
|
|
437
|
+
* the composer bar. Bounded so a badge in an unfamiliar layout never adopts
|
|
438
|
+
* some other composer's pills.
|
|
439
|
+
*/
|
|
440
|
+
function statsScopeOf(seat) {
|
|
441
|
+
let node = seat.parentElement;
|
|
442
|
+
for (let depth = 0; node !== null && depth < 4; depth++) {
|
|
443
|
+
if (node.querySelector('[data-composer-stats]') !== null)
|
|
444
|
+
return node;
|
|
445
|
+
node = node.parentElement;
|
|
446
|
+
}
|
|
447
|
+
return seat.parentElement;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* One account's handle and plan: `★ ys@example.com · 计划:pro`. The star
|
|
451
|
+
* marks the default account (the one direct routes serve and the collapsed
|
|
452
|
+
* pill reads), the same glyph Settings → 订阅 uses.
|
|
453
|
+
*/
|
|
454
|
+
function AccountMeta({ account, translate }) {
|
|
455
|
+
const parts = [account.account, account.plan === undefined ? undefined : translate('usagePlan', { plan: account.plan })]
|
|
456
|
+
.filter((part) => part !== undefined && part !== '');
|
|
457
|
+
return (_jsxs("span", { style: styles.providerMeta, title: account.account, children: [account.isDefault && _jsx("span", { style: styles.defaultStar, "aria-label": "default", children: "\u2605 " }), parts.join(' · ')] }));
|
|
458
|
+
}
|
|
459
|
+
/** Preview each account independently; all remaining quotas stay accessible. */
|
|
460
|
+
export function AccountWindows({ windows, model, provider, translate }) {
|
|
461
|
+
const { shown, hidden } = previewWindows(windows, model, provider);
|
|
462
|
+
const rows = (items) => (_jsx("dl", { style: styles.details, children: items.map((w, i) => (_jsx(WindowRow, { label: `${usageWindowLabel(translate, w)}${model !== undefined && w.scope === model ? ` · ${translate('usageBadgeCurrent')}` : ''}`, window: w }, i))) }));
|
|
463
|
+
return _jsxs(_Fragment, { children: [rows(shown), hidden.length > 0 && _jsxs("details", { style: styles.moreWindows, children: [_jsx("summary", { style: styles.moreSummary, children: translate('usageBadgeMoreWindows', { count: hidden.length }) }), rows(hidden)] })] });
|
|
464
|
+
}
|
|
465
|
+
/** One `dt`/`dd` pair: window name → `25% · 6d1h`, with the bar underneath. */
|
|
466
|
+
function WindowRow({ label, window: w }) {
|
|
467
|
+
const percent = usedPercent(w);
|
|
468
|
+
return (_jsxs(_Fragment, { children: [_jsx("dt", { style: styles.dt, children: label }), _jsxs("dd", { style: styles.dd, children: [percent, "%", w.resetsAt !== undefined && _jsxs("span", { style: styles.reset, children: [" \u00B7 ", windowLabel(w)] })] }), _jsx("div", { style: styles.bar, "aria-hidden": true, children: _jsx("div", { style: { ...styles.barFill, width: `${percent}%`, background: usageBarColor(percent) } }) })] }));
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Unplaced portal panel: hidden but laid out so the clamp measures real
|
|
472
|
+
* dimensions (the `useAnchoredPosition` measure pass).
|
|
473
|
+
*/
|
|
474
|
+
const MEASURE_STYLE = { visibility: 'hidden', left: 0, top: 0 };
|
|
475
|
+
const styles = {
|
|
476
|
+
seat: { display: 'none' },
|
|
477
|
+
anchor: { minWidth: 0, maxWidth: '100%', display: 'inline-flex' },
|
|
478
|
+
// Mirrors the host StatsPills pill so the badge reads as a sibling of the
|
|
479
|
+
// shipped time/token pills.
|
|
480
|
+
pill: {
|
|
481
|
+
boxSizing: 'border-box', maxWidth: '100%',
|
|
482
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
483
|
+
font: 'inherit', fontSize: 'var(--dsh-content-font-size-secondary, 13px)',
|
|
484
|
+
fontVariantNumeric: 'tabular-nums', lineHeight: '20px', whiteSpace: 'nowrap',
|
|
485
|
+
background: 'transparent', border: 'none', borderRadius: 24,
|
|
486
|
+
alignItems: 'center', gap: 6, padding: '1px 8px', display: 'inline-flex', cursor: 'pointer',
|
|
487
|
+
},
|
|
488
|
+
pillActive: {
|
|
489
|
+
background: 'var(--dsw-alias-interactive-bg-hover)',
|
|
490
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
491
|
+
},
|
|
492
|
+
label: { textOverflow: 'ellipsis', minWidth: 0, overflow: 'hidden' },
|
|
493
|
+
// Mirrors the host stat-dialog panel.
|
|
494
|
+
panel: {
|
|
495
|
+
position: 'fixed', zIndex: 1100, boxSizing: 'border-box',
|
|
496
|
+
background: 'var(--dsw-specific-menu)',
|
|
497
|
+
width: 'max-content', minWidth: 'min(300px, 100vw - 24px)', maxWidth: 'min(440px, 100vw - 24px)',
|
|
498
|
+
maxHeight: 'min(560px, 100dvh - 24px)', overflowY: 'auto', overscrollBehavior: 'contain',
|
|
499
|
+
boxShadow: 'var(--dsw-elevation-prominent)',
|
|
500
|
+
color: 'var(--dsw-alias-label-secondary)', cursor: 'default',
|
|
501
|
+
border: 0, borderRadius: 12, padding: 16, fontSize: 12, lineHeight: '18px',
|
|
502
|
+
},
|
|
503
|
+
title: {
|
|
504
|
+
color: 'var(--dsw-alias-label-primary)', display: 'flex',
|
|
505
|
+
justifyContent: 'space-between', gap: 16, marginBottom: 8, fontWeight: 500,
|
|
506
|
+
},
|
|
507
|
+
titleLabel: { alignItems: 'center', gap: 6, minWidth: 0, display: 'inline-flex' },
|
|
508
|
+
titleRule: { borderTop: '0.5px solid var(--dsw-alias-border-l2)', marginBottom: 10 },
|
|
509
|
+
section: { marginTop: 12, paddingTop: 10, borderTop: '0.5px solid var(--dsw-alias-border-l2)' },
|
|
510
|
+
providerRow: {
|
|
511
|
+
display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 16, marginBottom: 6,
|
|
512
|
+
},
|
|
513
|
+
providerName: { color: 'var(--dsw-alias-label-primary)', fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 6 },
|
|
514
|
+
currentTag: {
|
|
515
|
+
fontSize: 10, lineHeight: '14px', fontWeight: 400, padding: '0 5px', borderRadius: 7,
|
|
516
|
+
color: 'var(--dsw-alias-label-secondary)', background: 'var(--dsw-alias-interactive-bg-hover)',
|
|
517
|
+
},
|
|
518
|
+
providerMeta: {
|
|
519
|
+
color: 'var(--dsw-alias-label-tertiary)', minWidth: 0, overflow: 'hidden',
|
|
520
|
+
textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
521
|
+
},
|
|
522
|
+
// Same star and color as the default-account marker in Settings → 订阅.
|
|
523
|
+
defaultStar: { color: 'var(--dsw-alias-state-warn-label)' },
|
|
524
|
+
accountBlock: { marginTop: 8 },
|
|
525
|
+
accountRow: { display: 'flex', marginBottom: 4 },
|
|
526
|
+
details: {
|
|
527
|
+
color: 'var(--dsw-alias-label-tertiary)', display: 'grid',
|
|
528
|
+
gridTemplateColumns: 'minmax(0, 1fr) max-content', gap: '4px 16px', margin: 0,
|
|
529
|
+
},
|
|
530
|
+
moreWindows: { marginTop: 8 },
|
|
531
|
+
moreSummary: { cursor: 'pointer', color: 'var(--dsw-alias-label-secondary)', marginBottom: 8 },
|
|
532
|
+
dt: { minWidth: 0, margin: 0, overflowWrap: 'anywhere' },
|
|
533
|
+
dd: {
|
|
534
|
+
minWidth: 0, margin: 0, color: 'var(--dsw-alias-label-secondary)',
|
|
535
|
+
fontVariantNumeric: 'tabular-nums', textAlign: 'right',
|
|
536
|
+
},
|
|
537
|
+
reset: { color: 'var(--dsw-alias-label-tertiary)' },
|
|
538
|
+
bar: {
|
|
539
|
+
gridColumn: '1 / -1', height: 4, borderRadius: 2, overflow: 'hidden',
|
|
540
|
+
background: 'var(--dsw-alias-border-l2)', marginBottom: 2,
|
|
541
|
+
},
|
|
542
|
+
barFill: { height: '100%', borderRadius: 2 },
|
|
543
|
+
};
|