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 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 **subscription usage** pill showing the
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
- ![Subscription usage pill in the stats row](docs/images/usage-pill.png)
79
+ ![Provider usage pill in the stats row](docs/images/usage-pill.png)
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
- ![Subscription usage pill expanded](docs/images/usage-badge.png)
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
+ ![Usage dialog with elapsed-time cursors](docs/images/usage-badge.png)
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
- Expired, failed-refresh or more-than-five-minute-old readings retain the last reported green/red color, without pace warnings or markers. Cached numbers remain visible, with an accessible stale label; refresh to confirm them. 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) for rationale and verification limits.
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
- Unlimited, boosted-above-100% and unsupported quotas are omitted; if none remain,
197
- the card reports unavailable rather than inventing a percentage. Explicit
198
- remaining percentages take priority; older responses follow the official CLI's
199
- remaining-count compatibility convention. No live-account response was used in
200
- verification.
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.kind === 'session' ? t('usageSession')
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
- const collapsed = collapsedDisplays(displays, badgeSelection?.provider);
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
- const label = compactSegment(collapsed[0], badgeSelection?.model, translate);
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
- const expanded = expandedDisplays(displays, badgeSelection?.provider);
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 === badgeSelection?.provider ? badgeSelection.model : undefined, provider: d.provider, translate: translate }, `${d.provider}:${badgeSelection?.model ?? ''}`)] }, account.key)))] }, d.provider)))] }), document.body)] }));
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;
@@ -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
- const elapsed = fresh ? elapsedPercent(quota, current) : undefined;
24
- const state = usageColorState(quota.usedPercent, elapsed, preset, fresh);
25
- const lead = elapsed === undefined ? 0 : Math.round(used - elapsed);
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
- : elapsed === undefined && preset !== 'remaining' ? t('usageMeterUnknownTime')
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
- elapsed === undefined ? '' : lead > 0 ? t('usageMeterPaceAhead', { points: lead })
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, elapsed, preset, fresh) } }), elapsed !== undefined && _jsx("span", { "aria-hidden": "true", "data-usage-time-marker": true, style: {
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)',
@@ -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;
@@ -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
- /** Display-only preference: no credentials or account history are stored. */
2
- export type UsageBadgeMode = 'recent' | 'hidden';
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
- return window.localStorage.getItem(MODE_KEY) === 'hidden' ? 'hidden' : 'recent';
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
- /** Only explicit intervals or provider-verified fixed durations support linear pace. */
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;
@@ -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
- /** Only explicit intervals or provider-verified fixed durations support linear pace. */
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)