dsh-subscriptions 0.1.2 → 0.1.3
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/README.md +28 -10
- package/lib/client/ExternalUsageCards.d.ts +11 -1
- package/lib/client/ExternalUsageCards.js +18 -3
- package/lib/client/SubscriptionUsageBadge.d.ts +19 -2
- package/lib/client/SubscriptionUsageBadge.js +60 -5
- package/lib/client/UsageBadgeDisplaySetting.js +2 -2
- package/lib/client/UsageMeter.js +11 -6
- package/lib/client/locales.d.ts +2 -0
- package/lib/client/locales.js +4 -2
- package/lib/client/usage-badge-preferences.d.ts +7 -2
- package/lib/client/usage-badge-preferences.js +3 -2
- package/lib/client/usage-pace.d.ts +14 -1
- package/lib/client/usage-pace.js +14 -1
- package/lib/client.js +101 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +32 -5
- package/lib/providers/minimax-usage.d.ts +22 -1
- package/lib/providers/minimax-usage.js +37 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,13 +68,15 @@ and tool settings.
|
|
|
68
68
|
|
|
69
69
|
### Quota while you work
|
|
70
70
|
|
|
71
|
-
The composer's stats row gains a **
|
|
71
|
+
The composer's stats row gains a **provider usage** pill showing the
|
|
72
72
|
used percentage and reset window for the provider of the session's current
|
|
73
73
|
model. It shows at most one provider; switching to a non-subscription model
|
|
74
74
|
keeps the most recent subscription selected in the mounted conversation
|
|
75
|
-
view, or stays hidden if there is none.
|
|
75
|
+
view, or stays hidden if there is none. **Always show** instead rotates
|
|
76
|
+
through the providers that report usage every 10 seconds while the current
|
|
77
|
+
model has no quota source, so the pill never disappears.
|
|
76
78
|
|
|
77
|
-

|
|
78
80
|
|
|
79
81
|
Click the pill to expand every provider and account that reports usage —
|
|
80
82
|
the default account is starred, and the current provider is listed first.
|
|
@@ -83,7 +85,17 @@ neither do accounts without a usage window. Antigravity previews only the
|
|
|
83
85
|
current model's windows; the other model windows stay in a closed
|
|
84
86
|
disclosure.
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
Quota bars include a dark elapsed-time cursor whenever the provider's
|
|
89
|
+
timing places the window in time, including at 0% used and for stale or old
|
|
90
|
+
readings. Compare the colored fill (quota used) with the cursor (time
|
|
91
|
+
elapsed); hover for the percentages and reset countdown. Standard coloring
|
|
92
|
+
turns the fill orange when a fresh reading leads elapsed time by 10 points,
|
|
93
|
+
and Relaxed uses 15; 90% used turns it red and takes priority. Windows
|
|
94
|
+
without known timing omit the cursor, and a stale or old reading keeps its
|
|
95
|
+
cursor without a pace warning. Choose Standard, Relaxed, or Remaining colors
|
|
96
|
+
in **Settings → Subscriptions**.
|
|
97
|
+
|
|
98
|
+

|
|
87
99
|
|
|
88
100
|
## Install
|
|
89
101
|
|
|
@@ -183,7 +195,7 @@ Every preset shows reported usage of 90% or more in red, before considering pace
|
|
|
183
195
|
|
|
184
196
|
A vertical marker shows elapsed time only for an explicit valid start/end interval or a provider-verified fixed duration. A session/weekly label alone does not establish a fixed window; rolling or unknown intervals have no marker. Claude's recognized five-hour/seven-day buckets and Codex's reported quota durations supply fixed-window metadata. Unknown timing falls back to green below 90%, without a marker or yellow warning.
|
|
185
197
|
|
|
186
|
-
|
|
198
|
+
The marker depends on the window's timing alone, not on the reading: it stays at 0% used, for an invalid percentage, and when a refresh failed or the reading is more than five minutes old. Such a reading is not set against the current time, so it gets no yellow pace warning and its tooltip omits the ahead/behind comparison. It retains the last reported green/red color with an accessible stale label; refresh to confirm it. Once the reset time has passed the window has no position and the marker disappears. Invalid percentages show an unavailable state without a fabricated fill. Tooltips explain remaining quota, time progress and reset countdown where available. See the [decision record](.agents/notes/implemented/architecture/2026-10-01-usage-pace-colors.md) and the [cursor note](.agents/notes/implemented/architecture/2026-10-06-usage-cursor-timing-only.md) for rationale and verification limits.
|
|
187
199
|
|
|
188
200
|
### MiniMax subscription usage
|
|
189
201
|
|
|
@@ -193,11 +205,17 @@ The plugin reuses those credential references; no separate subscription ref is
|
|
|
193
205
|
required. Quota reads require subscription keys, not `sk-api-*` pay-as-you-go keys. The plugin reads the
|
|
194
206
|
region's `/v1/token_plan/remains` endpoint and displays finite per-model quotas
|
|
195
207
|
with API-provided start/end times. It does not add model routing or OAuth login.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
208
|
+
Rows are labelled with the model name, and the plugin shows the windows the API
|
|
209
|
+
returns for each model. MiniMax documents a five-hour and a weekly window for
|
|
210
|
+
non-video models and only a weekly one for video. Standard (non-video) models
|
|
211
|
+
on time-based plans report an explicit remaining percentage beside zero counts,
|
|
212
|
+
and that percentage is enough for a row. Unlimited, boosted-above-100% and
|
|
213
|
+
unsupported quotas are omitted (a status-3 row also covers a model the plan
|
|
214
|
+
does not include); if none remain, the card reports unavailable rather than
|
|
215
|
+
inventing a percentage. Explicit remaining percentages take priority; older
|
|
216
|
+
responses follow the official CLI's remaining-count compatibility convention. No
|
|
217
|
+
live-account response was used in verification, so a missing model may still
|
|
218
|
+
come from the account or the plan rather than from this plugin.
|
|
201
219
|
|
|
202
220
|
## Development
|
|
203
221
|
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
|
|
2
|
-
import type { SubscriptionsSectionInjected } from './SubscriptionsSection.js';
|
|
2
|
+
import type { SubscriptionsSectionInjected, UsageWindow } from './SubscriptionsSection.js';
|
|
3
3
|
type Translate = SubscriptionsSectionInjected['t'];
|
|
4
|
+
/**
|
|
5
|
+
* Row label of one window on an external usage card. A session or weekly
|
|
6
|
+
* window appends the model it belongs to when the provider names one (MiniMax
|
|
7
|
+
* reports a window per model), so two models' weekly rows stay distinguishable.
|
|
8
|
+
* The monthly pool is a scope on an `other` window, not a model.
|
|
9
|
+
* @param t - section translate.
|
|
10
|
+
* @param window - the reported window.
|
|
11
|
+
* @returns the localized label, e.g. "Weekly · general".
|
|
12
|
+
*/
|
|
13
|
+
export declare function externalUsageWindowLabel(t: Translate, window: UsageWindow): string;
|
|
4
14
|
/** Quota cards for API-key providers already available through DSH itself. */
|
|
5
15
|
export declare function ExternalUsageCards({ rpc, t }: {
|
|
6
16
|
rpc: ConnectionHandle['rpc'];
|
|
@@ -14,6 +14,23 @@ const SOURCES = [
|
|
|
14
14
|
function errorText(error) {
|
|
15
15
|
return error instanceof Error ? error.message : String(error);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Row label of one window on an external usage card. A session or weekly
|
|
19
|
+
* window appends the model it belongs to when the provider names one (MiniMax
|
|
20
|
+
* reports a window per model), so two models' weekly rows stay distinguishable.
|
|
21
|
+
* The monthly pool is a scope on an `other` window, not a model.
|
|
22
|
+
* @param t - section translate.
|
|
23
|
+
* @param window - the reported window.
|
|
24
|
+
* @returns the localized label, e.g. "Weekly · general".
|
|
25
|
+
*/
|
|
26
|
+
export function externalUsageWindowLabel(t, window) {
|
|
27
|
+
const named = window.scope !== undefined && window.scope !== '';
|
|
28
|
+
if (window.kind === 'other') {
|
|
29
|
+
return window.scope === 'Monthly' ? t('usageMonthly') : named ? window.scope : t('usageWindow');
|
|
30
|
+
}
|
|
31
|
+
const base = window.kind === 'session' ? t('usageSession') : t('usageWeekly');
|
|
32
|
+
return named ? `${base} · ${window.scope}` : base;
|
|
33
|
+
}
|
|
17
34
|
/** Quota cards for API-key providers already available through DSH itself. */
|
|
18
35
|
export function ExternalUsageCards({ rpc, t }) {
|
|
19
36
|
const [status, setStatus] = useState();
|
|
@@ -62,9 +79,7 @@ export function ExternalUsageCards({ rpc, t }) {
|
|
|
62
79
|
? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)' } }), _jsx("span", { style: styles.name, children: name })] }), !(status === undefined && statusError !== undefined) && _jsx("p", { style: styles.status, children: status === undefined ? t('checking')
|
|
63
80
|
: configured ? t('externalUsageConnected') : t('externalUsageNotConfigured') }), statusError !== undefined && _jsx("p", { style: styles.error, children: t('externalUsageUnavailable', { message: statusError }) }), status !== undefined && !configured && _jsx("p", { style: styles.status, children: t('externalUsageConfigure', { ref }) }), configured && _jsxs("div", { style: styles.account, children: [_jsxs("div", { style: styles.accountHeader, children: [_jsx("span", { style: styles.defaultStar, title: t('defaultBadge'), children: "\u2605" }), _jsx("span", { style: styles.accountName, children: t('externalUsageApiKey') })] }), _jsxs("div", { style: styles.usage, children: [_jsxs("div", { style: styles.usageHeader, children: [_jsx("span", { style: styles.usageTitle, children: t('usageTitle') }), snapshot?.plan !== undefined && _jsx("span", { style: styles.usagePlan, children: t('usagePlan', { plan: snapshot.plan }) }), _jsx("button", { type: "button", style: styles.usageRefresh, disabled: loading[id] === true, onClick: () => { void refresh(id); }, children: t('usageRefresh') })] }), loading[id] === true && snapshot === undefined && _jsx("p", { style: styles.status, children: t('usageLoading') }), errors[id] !== undefined && _jsx("p", { style: styles.error, children: t('usageError', { message: errors[id] }) }), snapshot?.windows?.length === 0 && _jsx("p", { style: styles.status, children: t('usageEmpty') }), snapshot?.windows?.map((window, index) => {
|
|
64
81
|
const percent = displayUsedPercent(window.usedPercent);
|
|
65
|
-
const label = window
|
|
66
|
-
: window.kind === 'weekly' ? t('usageWeekly')
|
|
67
|
-
: window.scope === 'Monthly' ? t('usageMonthly') : window.scope ?? t('usageWindow');
|
|
82
|
+
const label = externalUsageWindowLabel(t, window);
|
|
68
83
|
return _jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: label }), _jsxs("span", { children: [percent === undefined ? t('usageMeterInvalid') : `${percent}%`, window.resetsAt === undefined ? ''
|
|
69
84
|
: ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx(UsageMeter, { window: window, t: t, observedAt: observedAt[id], stale: errors[id] !== undefined || snapshot?.stale === true })] }, index);
|
|
70
85
|
})] })] }), !configured && _jsx("div", { style: styles.actions, children: _jsx("button", { type: "button", style: styles.button, onClick: () => { void refreshStatus(); }, children: t('usageRefresh') }) })] }, id);
|
|
@@ -20,7 +20,11 @@ export interface SubscriptionUsageBadgeInjected {
|
|
|
20
20
|
} | undefined>;
|
|
21
21
|
}
|
|
22
22
|
/** Props delivered by the slot outlet + inject + the locale seat. */
|
|
23
|
-
export type SubscriptionUsageBadgeProps = PropsRuntime<'conversation.composer.dock'> & Partial<SubscriptionUsageBadgeInjected> & Partial<PropsLocale<'settings.subscriptions'
|
|
23
|
+
export type SubscriptionUsageBadgeProps = PropsRuntime<'conversation.composer.dock'> & Partial<SubscriptionUsageBadgeInjected> & Partial<PropsLocale<'settings.subscriptions'>>
|
|
24
|
+
/** Override the rotation cadence (tests only; the slot never passes it). */
|
|
25
|
+
& {
|
|
26
|
+
rotationMs?: number;
|
|
27
|
+
};
|
|
24
28
|
/** One logged-in account's usage windows, as listed in the expanded dialog. */
|
|
25
29
|
export interface AccountUsageDisplay {
|
|
26
30
|
/** Account key (the `usage` endpoint's `account` argument). */
|
|
@@ -98,6 +102,19 @@ type ModelSelection = {
|
|
|
98
102
|
export declare function retainSubscriptionSelection(previous: ModelSelection | undefined, current: ModelSelection | undefined): ModelSelection | undefined;
|
|
99
103
|
/** At most one provider; no known selection or no usage means no badge. */
|
|
100
104
|
export declare function collapsedDisplays(displays: readonly ProviderUsageDisplay[], current: string | undefined): readonly ProviderUsageDisplay[];
|
|
105
|
+
/**
|
|
106
|
+
* `always` mode: the current model's provider while it reports usage,
|
|
107
|
+
* otherwise the rotation steps through the providers that do, in display
|
|
108
|
+
* order, wrapping around. The index is taken modulo the current provider
|
|
109
|
+
* count, so providers appearing or dropping out stay inside range; the
|
|
110
|
+
* sequence itself may shift when the roster changes. Unlike `recent`, the
|
|
111
|
+
* retained subscription does not pin the pill — that retention is exactly
|
|
112
|
+
* what this mode exists to escape.
|
|
113
|
+
* @param displays - providers with usage, in display order.
|
|
114
|
+
* @param current - the provider of the session's current model.
|
|
115
|
+
* @param step - rotation tick; consecutive ticks advance one provider.
|
|
116
|
+
*/
|
|
117
|
+
export declare function rotatingDisplay(displays: readonly ProviderUsageDisplay[], current: string | undefined, step: number): readonly ProviderUsageDisplay[];
|
|
101
118
|
/** Order for the expanded dialog: the current provider first, the rest in poll order. */
|
|
102
119
|
export declare function expandedDisplays(displays: readonly ProviderUsageDisplay[], current: string | undefined): readonly ProviderUsageDisplay[];
|
|
103
120
|
type Translate = (key: SubscriptionsKey, params?: Record<string, unknown>) => string;
|
|
@@ -107,7 +124,7 @@ type Translate = (key: SubscriptionsKey, params?: Record<string, unknown>) => st
|
|
|
107
124
|
* every provider's accounts and their windows. Returns null when no data is
|
|
108
125
|
* available.
|
|
109
126
|
*/
|
|
110
|
-
export declare function SubscriptionUsageBadge({ rpc, currentModel, t }: SubscriptionUsageBadgeProps): import("react").JSX.Element;
|
|
127
|
+
export declare function SubscriptionUsageBadge({ rpc, currentModel, t, rotationMs }: SubscriptionUsageBadgeProps): import("react").JSX.Element;
|
|
111
128
|
/** Preview each account independently; all remaining quotas stay accessible. */
|
|
112
129
|
export declare function AccountWindows({ windows, model, provider, translate, observedAt, stale }: {
|
|
113
130
|
windows: readonly UsageWindow[];
|
|
@@ -42,6 +42,8 @@ const UsageBadgeIcon = usageBadgeIcon(primitives);
|
|
|
42
42
|
const USAGE_POLL_INTERVAL_MS = 15 * 60_000;
|
|
43
43
|
/** How often the badge re-reads the session's current model (model switches arrive only by asking). */
|
|
44
44
|
const MODEL_POLL_INTERVAL_MS = 3000;
|
|
45
|
+
/** How long the rotation holds one provider in `always` mode (a display cadence, not a data poll). */
|
|
46
|
+
const BADGE_ROTATION_INTERVAL_MS = 10_000;
|
|
45
47
|
/** Distance between the trigger's top edge and the dialog's bottom (host stat dialogs use the same). */
|
|
46
48
|
const PANEL_GAP = 8;
|
|
47
49
|
/** Distance kept between the dialog and each viewport edge. */
|
|
@@ -236,6 +238,27 @@ export function collapsedDisplays(displays, current) {
|
|
|
236
238
|
const match = displays.find(d => d.provider === current);
|
|
237
239
|
return match === undefined ? [] : [match];
|
|
238
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* `always` mode: the current model's provider while it reports usage,
|
|
243
|
+
* otherwise the rotation steps through the providers that do, in display
|
|
244
|
+
* order, wrapping around. The index is taken modulo the current provider
|
|
245
|
+
* count, so providers appearing or dropping out stay inside range; the
|
|
246
|
+
* sequence itself may shift when the roster changes. Unlike `recent`, the
|
|
247
|
+
* retained subscription does not pin the pill — that retention is exactly
|
|
248
|
+
* what this mode exists to escape.
|
|
249
|
+
* @param displays - providers with usage, in display order.
|
|
250
|
+
* @param current - the provider of the session's current model.
|
|
251
|
+
* @param step - rotation tick; consecutive ticks advance one provider.
|
|
252
|
+
*/
|
|
253
|
+
export function rotatingDisplay(displays, current, step) {
|
|
254
|
+
if (displays.length === 0)
|
|
255
|
+
return [];
|
|
256
|
+
const match = displays.find(d => d.provider === current);
|
|
257
|
+
if (match !== undefined)
|
|
258
|
+
return [match];
|
|
259
|
+
const index = Number.isFinite(step) ? Math.floor(step) : 0;
|
|
260
|
+
return [displays[((index % displays.length) + displays.length) % displays.length]];
|
|
261
|
+
}
|
|
239
262
|
/** Order for the expanded dialog: the current provider first, the rest in poll order. */
|
|
240
263
|
export function expandedDisplays(displays, current) {
|
|
241
264
|
const match = displays.find(d => d.provider === current);
|
|
@@ -271,7 +294,7 @@ function usageWindowLabel(t, window) {
|
|
|
271
294
|
* every provider's accounts and their windows. Returns null when no data is
|
|
272
295
|
* available.
|
|
273
296
|
*/
|
|
274
|
-
export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
|
|
297
|
+
export function SubscriptionUsageBadge({ rpc, currentModel, t, rotationMs }) {
|
|
275
298
|
const translate = t ?? fallbackTranslate;
|
|
276
299
|
const [displays, setDisplays] = useState([]);
|
|
277
300
|
const [selection, setSelection] = useState(undefined);
|
|
@@ -281,6 +304,11 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
|
|
|
281
304
|
const badgeSelection = retainSubscriptionSelection(lastSubscription, selection);
|
|
282
305
|
const [open, setOpen] = useState(false);
|
|
283
306
|
const [hover, setHover] = useState(false);
|
|
307
|
+
// Rotation tick for `always` mode; the interval runs only in that mode.
|
|
308
|
+
const [rotation, setRotation] = useState(0);
|
|
309
|
+
// The provider the pill showed when the dialog opened; an open dialog
|
|
310
|
+
// keeps leading with it while the rotation keeps ticking underneath.
|
|
311
|
+
const [dialogLead, setDialogLead] = useState(undefined);
|
|
284
312
|
const inflightRef = useRef(false);
|
|
285
313
|
const mountedRef = useRef(true);
|
|
286
314
|
const rootRef = useRef(null);
|
|
@@ -443,22 +471,49 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
|
|
|
443
471
|
if (displayMode === 'hidden')
|
|
444
472
|
setOpen(false);
|
|
445
473
|
}, [displayMode]);
|
|
474
|
+
// `always` mode advances the rotation on its own cadence; other modes
|
|
475
|
+
// never advance, so the pill never changes on its own.
|
|
476
|
+
useEffect(() => {
|
|
477
|
+
if (displayMode !== 'always')
|
|
478
|
+
return;
|
|
479
|
+
const interval = rotationMs !== undefined && Number.isFinite(rotationMs) && rotationMs > 0
|
|
480
|
+
? rotationMs
|
|
481
|
+
: BADGE_ROTATION_INTERVAL_MS;
|
|
482
|
+
const timer = setInterval(() => { setRotation(step => step + 1); }, interval);
|
|
483
|
+
return () => { clearInterval(timer); };
|
|
484
|
+
}, [displayMode, rotationMs]);
|
|
446
485
|
const seat = _jsx("span", { ref: seatRef, style: styles.seat, "aria-hidden": true });
|
|
447
|
-
|
|
486
|
+
// `always` follows the live model, not the retained subscription: the
|
|
487
|
+
// retention pins the pill in `recent`, which is what this mode escapes.
|
|
488
|
+
const collapsed = displayMode === 'always'
|
|
489
|
+
? rotatingDisplay(displays, current, rotation)
|
|
490
|
+
: collapsedDisplays(displays, badgeSelection?.provider);
|
|
448
491
|
if (displayMode === 'hidden' || collapsed.length === 0)
|
|
449
492
|
return seat;
|
|
450
|
-
|
|
493
|
+
// Model scope only while the pill shows the current model's provider;
|
|
494
|
+
// a rotated provider has no live model, so it reads at account level.
|
|
495
|
+
const pillModel = displayMode === 'always'
|
|
496
|
+
? (current === collapsed[0]?.provider ? selection?.model : undefined)
|
|
497
|
+
: badgeSelection?.model;
|
|
498
|
+
const label = compactSegment(collapsed[0], pillModel, translate);
|
|
451
499
|
// The dialog opens from the badge, so it follows the provider and model the
|
|
452
500
|
// badge shows (a retained subscription when the current model is not one).
|
|
453
|
-
|
|
501
|
+
// In `always` mode the pill moves under the cursor, so an open dialog keeps
|
|
502
|
+
// the provider that was showing when it opened instead of reordering live.
|
|
503
|
+
const dialogProvider = open && displayMode === 'always' ? dialogLead : badgeSelection?.provider;
|
|
504
|
+
const dialogModel = open && displayMode === 'always'
|
|
505
|
+
? (dialogLead === current ? selection?.model : undefined)
|
|
506
|
+
: badgeSelection?.model;
|
|
507
|
+
const expanded = expandedDisplays(displays, dialogProvider);
|
|
454
508
|
const title = translate('usageBadgeTitle');
|
|
455
509
|
const toggle = () => {
|
|
456
510
|
const next = !open;
|
|
511
|
+
setDialogLead(collapsed[0]?.provider);
|
|
457
512
|
setOpen(next);
|
|
458
513
|
if (next)
|
|
459
514
|
void refresh();
|
|
460
515
|
};
|
|
461
|
-
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, observedAt: account.observedAt, stale: account.stale, model: d.provider ===
|
|
516
|
+
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, observedAt: account.observedAt, stale: account.stale, model: d.provider === dialogProvider ? dialogModel : undefined, provider: d.provider, translate: translate }, `${d.provider}:${dialogModel ?? ''}`)] }, account.key)))] }, d.provider)))] }), document.body)] }));
|
|
462
517
|
return (_jsxs(_Fragment, { children: [seat, statsRow !== null && statsRow.isConnected ? createPortal(pill, statsRow) : pill] }));
|
|
463
518
|
}
|
|
464
519
|
/**
|
|
@@ -9,7 +9,7 @@ export function UsageBadgeDisplaySetting({ t }) {
|
|
|
9
9
|
const [failed, setFailed] = useState(false);
|
|
10
10
|
return (_jsxs("div", { style: styles.card, children: [_jsxs("label", { style: styles.field, children: [_jsx("span", { children: t('usageBadgeDisplay') }), _jsxs("select", { style: styles.select, "aria-label": t('usageBadgeDisplay'), value: mode, onChange: event => {
|
|
11
11
|
const value = event.currentTarget.value;
|
|
12
|
-
if (value !== 'recent' && value !== 'hidden')
|
|
12
|
+
if (value !== 'recent' && value !== 'always' && value !== 'hidden')
|
|
13
13
|
return;
|
|
14
14
|
try {
|
|
15
15
|
setUsageBadgeMode(value);
|
|
@@ -20,7 +20,7 @@ export function UsageBadgeDisplaySetting({ t }) {
|
|
|
20
20
|
throw error;
|
|
21
21
|
setFailed(true);
|
|
22
22
|
}
|
|
23
|
-
}, children: [_jsx("option", { value: "recent", children: t('usageBadgeDisplayRecent') }), _jsx("option", { value: "hidden", children: t('usageBadgeDisplayHidden') })] })] }), _jsx("p", { style: styles.hint, children: t('usageBadgeDisplayHint') }), _jsxs("label", { style: styles.field, children: [_jsx("span", { children: t('usageColorLabel') }), _jsxs("select", { style: styles.select, "aria-label": t('usageColorLabel'), value: preset, onChange: event => {
|
|
23
|
+
}, children: [_jsx("option", { value: "recent", children: t('usageBadgeDisplayRecent') }), _jsx("option", { value: "always", children: t('usageBadgeDisplayAlways') }), _jsx("option", { value: "hidden", children: t('usageBadgeDisplayHidden') })] })] }), _jsx("p", { style: styles.hint, children: t('usageBadgeDisplayHint') }), _jsxs("label", { style: styles.field, children: [_jsx("span", { children: t('usageColorLabel') }), _jsxs("select", { style: styles.select, "aria-label": t('usageColorLabel'), value: preset, onChange: event => {
|
|
24
24
|
const value = event.currentTarget.value;
|
|
25
25
|
if (!isUsageColorPreset(value))
|
|
26
26
|
return;
|
package/lib/client/UsageMeter.js
CHANGED
|
@@ -20,17 +20,22 @@ export function UsageMeter({ window: quota, t, style, observedAt, stale }) {
|
|
|
20
20
|
const valid = validUsage(quota.usedPercent);
|
|
21
21
|
const used = valid ? quota.usedPercent : 0;
|
|
22
22
|
const fresh = isUsageFresh(quota, { observedAt, stale }, current);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
23
|
+
// The cursor is where the window stands in time. It needs only the window's
|
|
24
|
+
// own timing, so it stays for an unused, stale, aged or unobserved reading.
|
|
25
|
+
const elapsed = elapsedPercent(quota, current);
|
|
26
|
+
// Comparing usage with elapsed time is meaningful only while the percentage
|
|
27
|
+
// is current; an old one is not set against today's clock.
|
|
28
|
+
const pace = fresh ? elapsed : undefined;
|
|
29
|
+
const state = usageColorState(quota.usedPercent, pace, preset, fresh);
|
|
30
|
+
const lead = pace === undefined ? 0 : Math.round(used - pace);
|
|
26
31
|
const label = [
|
|
27
32
|
valid ? t('usageMeterUsed', { used: Math.round(used), remaining: Math.round(100 - used) }) : t('usageMeterInvalid'),
|
|
28
33
|
// An invalid reading already states unavailability; the stale line would repeat it.
|
|
29
34
|
!valid ? '' : !fresh ? t('usageMeterStale') : state === 'red' ? t('usageMeterNearLimit')
|
|
30
|
-
:
|
|
35
|
+
: pace === undefined && preset !== 'remaining' ? t('usageMeterUnknownTime')
|
|
31
36
|
: state === 'yellow' ? t('usageMeterAhead') : t('usageMeterNoWarning'),
|
|
32
37
|
elapsed === undefined ? '' : t('usageMeterPace', { elapsed: Math.round(elapsed) }),
|
|
33
|
-
|
|
38
|
+
pace === undefined ? '' : lead > 0 ? t('usageMeterPaceAhead', { points: lead })
|
|
34
39
|
: lead < 0 ? t('usageMeterPaceBehind', { points: -lead }) : t('usageMeterPaceEven'),
|
|
35
40
|
quota.resetsAt !== undefined && quota.resetsAt > current ? t('usageMeterReset', {
|
|
36
41
|
duration: resetCountdownParts(quota.resetsAt - current)
|
|
@@ -43,7 +48,7 @@ export function UsageMeter({ window: quota, t, style, observedAt, stale }) {
|
|
|
43
48
|
background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
|
|
44
49
|
...style, position: 'relative',
|
|
45
50
|
}, children: [_jsx("div", { style: { height: '100%', borderRadius: 'inherit', width: `${used}%`,
|
|
46
|
-
background: usageBarColor(quota.usedPercent,
|
|
51
|
+
background: usageBarColor(quota.usedPercent, pace, preset, fresh) } }), elapsed !== undefined && _jsx("span", { "aria-hidden": "true", "data-usage-time-marker": true, style: {
|
|
47
52
|
position: 'absolute', left: `clamp(0px, calc(${elapsed}% - 1px), calc(100% - 2px))`,
|
|
48
53
|
top: 0, bottom: 0, width: 2, background: 'var(--dsw-alias-label-primary)',
|
|
49
54
|
boxShadow: '0 0 0 1px var(--dsw-alias-bg-layer-1)',
|
package/lib/client/locales.d.ts
CHANGED
|
@@ -155,6 +155,7 @@ export declare const en: {
|
|
|
155
155
|
usageBadgeTitle: string;
|
|
156
156
|
usageBadgeDisplay: string;
|
|
157
157
|
usageBadgeDisplayRecent: string;
|
|
158
|
+
usageBadgeDisplayAlways: string;
|
|
158
159
|
usageBadgeDisplayHidden: string;
|
|
159
160
|
usageBadgeDisplayHint: string;
|
|
160
161
|
usageBadgeDisplaySaveFailed: string;
|
|
@@ -326,6 +327,7 @@ export declare const zh: {
|
|
|
326
327
|
usageBadgeTitle: string;
|
|
327
328
|
usageBadgeDisplay: string;
|
|
328
329
|
usageBadgeDisplayRecent: string;
|
|
330
|
+
usageBadgeDisplayAlways: string;
|
|
329
331
|
usageBadgeDisplayHidden: string;
|
|
330
332
|
usageBadgeDisplayHint: string;
|
|
331
333
|
usageBadgeDisplaySaveFailed: string;
|
package/lib/client/locales.js
CHANGED
|
@@ -155,8 +155,9 @@ export const en = {
|
|
|
155
155
|
usageBadgeTitle: 'Provider usage',
|
|
156
156
|
usageBadgeDisplay: 'Status-bar quota display',
|
|
157
157
|
usageBadgeDisplayRecent: 'Current / most recent provider with usage',
|
|
158
|
+
usageBadgeDisplayAlways: 'Always show one provider, rotating through the rest',
|
|
158
159
|
usageBadgeDisplayHidden: 'Hidden',
|
|
159
|
-
usageBadgeDisplayHint: 'Saved automatically in this browser. One provider is shown at a time. Models without a quota source keep the most recent provider selected in this view, or show nothing. Hiding does not affect usage details here.',
|
|
160
|
+
usageBadgeDisplayHint: 'Saved automatically in this browser. One provider is shown at a time. Models without a quota source keep the most recent provider selected in this view, or show nothing. Always show instead rotates through the providers with usage every 10 seconds while the current model has no quota source. Hiding does not affect usage details here.',
|
|
160
161
|
usageBadgeDisplaySaveFailed: 'Could not save the display preference. Check browser storage permissions.',
|
|
161
162
|
usageBadgeCurrent: 'current',
|
|
162
163
|
usageBadgeModelCount: '{count} model quotas',
|
|
@@ -326,8 +327,9 @@ export const zh = {
|
|
|
326
327
|
usageBadgeTitle: '服务商用量',
|
|
327
328
|
usageBadgeDisplay: '状态栏额度显示',
|
|
328
329
|
usageBadgeDisplayRecent: '仅显示当前/最近使用的用量服务商',
|
|
330
|
+
usageBadgeDisplayAlways: '始终显示一家,其余服务商轮播',
|
|
329
331
|
usageBadgeDisplayHidden: '不显示',
|
|
330
|
-
usageBadgeDisplayHint: '
|
|
332
|
+
usageBadgeDisplayHint: '自动保存到当前浏览器。状态栏一次显示一家服务商;没有额度来源的模型沿用本视图最近选择的服务商,没有记录则隐藏。选择“始终显示”时,当前模型没有额度来源,就每 10 秒轮播一家有额度的服务商。关闭不影响本页的额度详情。',
|
|
331
333
|
usageBadgeDisplaySaveFailed: '显示设置保存失败,请检查浏览器存储权限。',
|
|
332
334
|
usageBadgeCurrent: '当前',
|
|
333
335
|
usageBadgeModelCount: '{count} 个模型配额',
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Display-only preference: no credentials or account history are stored.
|
|
3
|
+
* `recent` pins the current/most-recent provider; `always` keeps a provider
|
|
4
|
+
* visible at all times, rotating through the others when the current model
|
|
5
|
+
* has no quota source; `hidden` shows nothing.
|
|
6
|
+
*/
|
|
7
|
+
export type UsageBadgeMode = 'recent' | 'always' | 'hidden';
|
|
3
8
|
export declare const USAGE_BADGE_REFRESH_EVENT = "dsh:subscriptions:usage-refresh";
|
|
4
9
|
/** Read the browser preference; blocked storage retains the default display. */
|
|
5
10
|
export declare function readUsageBadgeMode(): UsageBadgeMode;
|
|
@@ -7,7 +7,8 @@ export function readUsageBadgeMode() {
|
|
|
7
7
|
if (typeof window === 'undefined')
|
|
8
8
|
return 'recent';
|
|
9
9
|
try {
|
|
10
|
-
|
|
10
|
+
const value = window.localStorage.getItem(MODE_KEY);
|
|
11
|
+
return value === 'hidden' || value === 'always' ? value : 'recent';
|
|
11
12
|
}
|
|
12
13
|
catch (error) {
|
|
13
14
|
if (!(error instanceof DOMException) || error.name !== 'SecurityError')
|
|
@@ -17,7 +18,7 @@ export function readUsageBadgeMode() {
|
|
|
17
18
|
}
|
|
18
19
|
/** Save first, then notify; the settings control reports a rejected write. */
|
|
19
20
|
export function setUsageBadgeMode(mode) {
|
|
20
|
-
if (mode !== 'recent' && mode !== 'hidden')
|
|
21
|
+
if (mode !== 'recent' && mode !== 'always' && mode !== 'hidden')
|
|
21
22
|
throw new TypeError('Invalid subscription usage display mode');
|
|
22
23
|
window.localStorage.setItem(MODE_KEY, mode);
|
|
23
24
|
window.dispatchEvent(new Event(MODE_EVENT));
|
|
@@ -22,8 +22,21 @@ export interface CountdownPart {
|
|
|
22
22
|
* window never announces zero remaining time. Always at least one minute.
|
|
23
23
|
*/
|
|
24
24
|
export declare function resetCountdownParts(ms: number): CountdownPart[];
|
|
25
|
+
/**
|
|
26
|
+
* Whether the reported percentage still describes the window now. Only a fresh
|
|
27
|
+
* reading may be compared with the current time (pace warnings); an old one
|
|
28
|
+
* keeps its absolute color. This does not decide whether the time cursor is
|
|
29
|
+
* drawn: where the window stands in time never depends on the reading.
|
|
30
|
+
*/
|
|
25
31
|
export declare function isUsageFresh(window: UsageWindow, observation: UsageObservation, now?: number): boolean;
|
|
26
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* How far through its window the clock is, from the window's own timing alone.
|
|
34
|
+
* It ignores the percentage and how recently it was read, so an unused,
|
|
35
|
+
* stale, aged or unobserved reading keeps its cursor whenever the timing
|
|
36
|
+
* places it. Returns undefined when the timing cannot: only an explicit
|
|
37
|
+
* interval or a provider-verified fixed duration supports linear pace, and no
|
|
38
|
+
* start is invented from a window's kind or label.
|
|
39
|
+
*/
|
|
27
40
|
export declare function elapsedPercent(window: UsageWindow, now?: number): number | undefined;
|
|
28
41
|
export declare function usageColorState(used: number, elapsed: number | undefined, preset: UsageColorPreset, fresh: boolean): UsageColorState;
|
|
29
42
|
export declare function usageBarColor(used: number, elapsed?: number, preset?: UsageColorPreset, fresh?: boolean): string;
|
package/lib/client/usage-pace.js
CHANGED
|
@@ -23,13 +23,26 @@ export function resetCountdownParts(ms) {
|
|
|
23
23
|
];
|
|
24
24
|
return parts.filter(part => part.count > 0).slice(0, 2);
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether the reported percentage still describes the window now. Only a fresh
|
|
28
|
+
* reading may be compared with the current time (pace warnings); an old one
|
|
29
|
+
* keeps its absolute color. This does not decide whether the time cursor is
|
|
30
|
+
* drawn: where the window stands in time never depends on the reading.
|
|
31
|
+
*/
|
|
26
32
|
export function isUsageFresh(window, observation, now = Date.now()) {
|
|
27
33
|
return validUsage(window.usedPercent) && observation.stale !== true
|
|
28
34
|
&& observation.observedAt !== undefined && Number.isFinite(observation.observedAt)
|
|
29
35
|
&& observation.observedAt <= now && now - observation.observedAt <= USAGE_FRESHNESS_MS
|
|
30
36
|
&& (window.resetsAt === undefined || Number.isFinite(window.resetsAt) && window.resetsAt > now);
|
|
31
37
|
}
|
|
32
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* How far through its window the clock is, from the window's own timing alone.
|
|
40
|
+
* It ignores the percentage and how recently it was read, so an unused,
|
|
41
|
+
* stale, aged or unobserved reading keeps its cursor whenever the timing
|
|
42
|
+
* places it. Returns undefined when the timing cannot: only an explicit
|
|
43
|
+
* interval or a provider-verified fixed duration supports linear pace, and no
|
|
44
|
+
* start is invented from a window's kind or label.
|
|
45
|
+
*/
|
|
33
46
|
export function elapsedPercent(window, now = Date.now()) {
|
|
34
47
|
const end = window.resetsAt;
|
|
35
48
|
if (end === undefined || !Number.isFinite(end) || end <= now)
|