dsh-subscriptions 0.1.0 → 0.1.2

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.
Files changed (38) hide show
  1. package/README.md +99 -6
  2. package/icon.svg +28 -0
  3. package/lib/client/CursorCard.js +15 -11
  4. package/lib/client/ExternalUsageCards.js +11 -11
  5. package/lib/client/SubscriptionUsageBadge.d.ts +6 -2
  6. package/lib/client/SubscriptionUsageBadge.js +56 -24
  7. package/lib/client/SubscriptionsSection.d.ts +7 -2
  8. package/lib/client/SubscriptionsSection.js +9 -13
  9. package/lib/client/UsageBadgeDisplaySetting.js +18 -1
  10. package/lib/client/UsageMeter.d.ts +8 -0
  11. package/lib/client/UsageMeter.js +51 -0
  12. package/lib/client/host-contract.d.ts +1 -1
  13. package/lib/client/host-contract.js +1 -0
  14. package/lib/client/locales.d.ts +48 -0
  15. package/lib/client/locales.js +48 -0
  16. package/lib/client/subscription-card-styles.js +0 -5
  17. package/lib/client/usage-color-preferences.d.ts +6 -0
  18. package/lib/client/usage-color-preferences.js +38 -0
  19. package/lib/client/usage-pace.d.ts +29 -0
  20. package/lib/client/usage-pace.js +58 -0
  21. package/lib/client.js +396 -114
  22. package/lib/client.js.map +1 -1
  23. package/lib/index.js +225 -35
  24. package/lib/providers/claude.js +7 -0
  25. package/lib/providers/codex.js +5 -0
  26. package/lib/providers/common.d.ts +10 -0
  27. package/lib/providers/cursor-usage.js +6 -2
  28. package/lib/providers/external-usage-controller.d.ts +1 -1
  29. package/lib/providers/external-usage-controller.js +8 -3
  30. package/lib/providers/external-usage.d.ts +1 -1
  31. package/lib/providers/external-usage.js +62 -2
  32. package/lib/providers/grok.js +3 -1
  33. package/lib/providers/minimax-usage.d.ts +3 -0
  34. package/lib/providers/minimax-usage.js +55 -0
  35. package/lib/providers/pool-usage.js +6 -4
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +10 -6
package/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # DSH Subscription Hub
1
+ # DSH Subscriptions
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-subscriptions)](https://www.npmjs.com/package/dsh-subscriptions)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
2
5
 
3
6
  Bring your AI subscriptions into
4
7
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). One
@@ -23,12 +26,74 @@ reported quota in **Settings → Subscriptions**.
23
26
  image generation and editing, and Grok video generation when the matching
24
27
  provider is enabled.
25
28
 
29
+ ## Screenshots
30
+
31
+ Captured against DSH 0.2.0-rc.2 in the web UI with a local stub serving
32
+ frozen provider responses, so each image shows a product state rather than
33
+ a real account. Identities in the images are fabricated, and the quota
34
+ percentages, catalogs, and reset countdowns are sample data.
35
+
36
+ ### Subscriptions
37
+
38
+ Per-provider quota windows, account controls, and the status-bar display
39
+ preference live in **Settings → Subscriptions**.
40
+
41
+ ![Subscriptions settings page](docs/images/subscriptions.png)
42
+
43
+ ### Model selection
44
+
45
+ Logged-in providers join the session model picker with their live catalogs.
46
+
47
+ ![Model picker with subscription models](docs/images/model-picker.png)
48
+
49
+ Models that advertise reasoning levels get an **Effort** selector in the
50
+ same menu. Levels and defaults come from the provider's live catalog;
51
+ without discovery Codex falls back to a built-in list, and a configured
52
+ default overrides either.
53
+
54
+ ![Reasoning effort selector](docs/images/model-effort.png)
55
+
56
+ Codex models whose catalog advertises the fast tier get a **Speed** toggle
57
+ in the composer's tool row: Standard or Fast, per session.
58
+
59
+ ![Speed toggle with the Standard/Fast menu open](docs/images/speed-toggle.png)
60
+
61
+ **Settings → Subscriptions → provider → Manage** edits account aliases,
62
+ pool participation, model allowlists, and the per-model effort, context,
63
+ and tool settings.
64
+
65
+ ![Model settings](docs/images/model-settings.png)
66
+
67
+ ![Provider tools](docs/images/provider-tools.png)
68
+
69
+ ### Quota while you work
70
+
71
+ The composer's stats row gains a **subscription usage** pill showing the
72
+ used percentage and reset window for the provider of the session's current
73
+ model. It shows at most one provider; switching to a non-subscription model
74
+ keeps the most recent subscription selected in the mounted conversation
75
+ view, or stays hidden if there is none.
76
+
77
+ ![Subscription usage pill in the stats row](docs/images/usage-pill.png)
78
+
79
+ Click the pill to expand every provider and account that reports usage —
80
+ the default account is starred, and the current provider is listed first.
81
+ A provider with no usage endpoint, such as GitHub Copilot, has no row, and
82
+ neither do accounts without a usage window. Antigravity previews only the
83
+ current model's windows; the other model windows stay in a closed
84
+ disclosure.
85
+
86
+ ![Subscription usage pill expanded](docs/images/usage-badge.png)
87
+
26
88
  ## Install
27
89
 
28
90
  Current source is tested with DSH `0.1.7-rc.2`, `0.2.0-rc.1`, and
29
91
  `0.2.0-rc.2`. The package's peers admit `>=0.1.7-rc.2 <0.3.0-0`: untested
30
- versions inside that range install but are unsupported, and versions outside
31
- it need an explicit `dsh plugin allow-version` exemption. See
92
+ versions inside that range install but are unsupported, and versions
93
+ outside it need an explicit `dsh plugin allow-version` exemption.
94
+ `dsh plugin add` checks those ranges with `includePrerelease` semantics, so
95
+ the admitted set covers every host build from `0.1.7-rc.2` through the
96
+ whole `0.2.x` line, prereleases included, and blocks the `0.3.0` line. See
32
97
  [docs/compatibility.md](docs/compatibility.md)
33
98
  for the support window and [docs/testing.md](docs/testing.md) for the test
34
99
  layers. With `dsh` available, install the plugin into the web profile from
@@ -41,7 +106,7 @@ dsh plugin --profile web add dsh-subscriptions
41
106
  Or from GitHub source:
42
107
 
43
108
  ```sh
44
- dsh plugin --profile web add github:goodboys-ai/dsh-subscription-hub
109
+ dsh plugin --profile web add github:goodboys-ai/dsh-subscriptions
45
110
  ```
46
111
 
47
112
  Git installs run this package's `prepare` script to build the host and browser
@@ -74,7 +139,7 @@ spec (`#vX.Y.Z`); see the
74
139
 
75
140
  ## Verification and limits
76
141
 
77
- CI builds, tests, and boots a packed tarball on both supported DSH versions;
142
+ CI builds, tests, and boots a packed tarball on all supported DSH versions;
78
143
  it does not run the GitHub source install's `prepare` step. Offline
79
144
  provider tests check recorded API responses, so a passing CI run cannot prove
80
145
  that a provider still accepts live sign-in or model requests. See
@@ -106,6 +171,34 @@ usage.
106
171
  The plugin follows DSH network settings. Its former proxy configuration and
107
172
  UI have been retired; an old plugin proxy config file is ignored.
108
173
 
174
+ ## Usage coloring
175
+
176
+ **Settings → Subscriptions → Usage coloring** offers one browser-local dropdown:
177
+
178
+ - **Standard** (default): yellow when used quota leads elapsed time by at least 10 percentage points.
179
+ - **Relaxed**: yellow at a lead of at least 15 points.
180
+ - **Remaining quota only**: no yellow pace warnings.
181
+
182
+ Every preset shows reported usage of 90% or more in red, before considering pace. For example, 40% used after 25% of a window is a 15-point lead, not a consumption forecast. Preferences apply to all meters, survive reloads and synchronize across tabs of the same origin. They change display only, never account routing or quota enforcement.
183
+
184
+ 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
+
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.
187
+
188
+ ### MiniMax subscription usage
189
+
190
+ Configure `MINIMAX_API_KEY` for the global service or
191
+ `MINIMAX_CN_API_KEY` for China, using DSH's standard model-key configuration.
192
+ The plugin reuses those credential references; no separate subscription ref is
193
+ required. Quota reads require subscription keys, not `sk-api-*` pay-as-you-go keys. The plugin reads the
194
+ region's `/v1/token_plan/remains` endpoint and displays finite per-model quotas
195
+ 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.
201
+
109
202
  ## Development
110
203
 
111
204
  ```sh
@@ -122,7 +215,7 @@ contract checks, the boot smoke, and the host E2E) are documented in [docs/testi
122
215
  support window and release process in
123
216
  [docs/compatibility.md](docs/compatibility.md).
124
217
 
125
- The package is published to npm as `dsh-subscriptions`, starting with the first release under the new name (GitHub source installation remains supported). The `cordis.patch.yml` bundle entry and browser module ID use the same package name. Earlier releases were published as `@goodboys-ai/dsh-subscription-hub`; that name will be deprecated in favor of `dsh-subscriptions`. If the old scoped package is installed, remove it before adding the new one — installing both registers the same adapters twice and breaks plugin load.
218
+ The package is published to npm as `dsh-subscriptions`, starting with the first release under the new name (GitHub source installation remains supported). The `cordis.patch.yml` bundle entry and browser module ID use the same package name. Earlier releases were published as `@goodboys-ai/dsh-subscription-hub`; that name is deprecated and points here. If the old scoped package is installed, remove it before adding the new one — installing both registers the same adapters twice and breaks plugin load.
126
219
 
127
220
  ## Origins
128
221
 
package/icon.svg ADDED
@@ -0,0 +1,28 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="256" y2="256" gradientUnits="userSpaceOnUse">
4
+ <stop offset="0" stop-color="#2f6df6"/>
5
+ <stop offset="1" stop-color="#0bb6a8"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="256" height="256" rx="58" fill="url(#bg)"/>
9
+ <g stroke="#ffffff" stroke-opacity="0.65" stroke-width="7" stroke-linecap="round">
10
+ <line x1="128" y1="128" x2="54" y2="64"/>
11
+ <line x1="128" y1="128" x2="202" y2="64"/>
12
+ <line x1="128" y1="128" x2="42" y2="150"/>
13
+ <line x1="128" y1="128" x2="214" y2="150"/>
14
+ <line x1="128" y1="128" x2="78" y2="208"/>
15
+ <line x1="128" y1="128" x2="178" y2="208"/>
16
+ </g>
17
+ <g fill="#ffffff">
18
+ <circle cx="54" cy="64" r="17"/>
19
+ <circle cx="202" cy="64" r="17"/>
20
+ <circle cx="42" cy="150" r="17"/>
21
+ <circle cx="214" cy="150" r="17"/>
22
+ <circle cx="78" cy="208" r="17"/>
23
+ <circle cx="178" cy="208" r="17"/>
24
+ </g>
25
+ <circle cx="128" cy="128" r="37" fill="#ffffff"/>
26
+ <circle cx="128" cy="128" r="37" fill="none" stroke="#0b3b5e" stroke-opacity="0.18" stroke-width="3"/>
27
+ <circle cx="128" cy="128" r="15" fill="#0e7490"/>
28
+ </svg>
@@ -4,20 +4,17 @@ import { callSubscriptionsAuth } from './subscriptions-rpc.js';
4
4
  import { ProviderAccountManager } from './ProviderAccountManager.js';
5
5
  import { USAGE_BADGE_REFRESH_EVENT } from './usage-badge-preferences.js';
6
6
  import { subscriptionCardStyles as styles } from './subscription-card-styles.js';
7
+ import { UsageMeter } from './UsageMeter.js';
8
+ import { displayUsedPercent } from './usage-pace.js';
7
9
  function message(error) {
8
10
  return error instanceof Error ? error.message : String(error);
9
11
  }
10
- function barColor(percent) {
11
- if (percent >= 95)
12
- return 'var(--dsw-alias-state-error-primary)';
13
- if (percent >= 80)
14
- return 'var(--dsw-alias-state-warn-label)';
15
- return 'var(--dsw-alias-state-success-primary)';
16
- }
17
12
  /** Cursor's single connected account, rendered with the same card and Manage dialog as the other providers. */
18
13
  export function CursorCard({ rpc, t }) {
19
14
  const [status, setStatus] = useState();
20
15
  const [usage, setUsage] = useState();
16
+ const [observedAt, setObservedAt] = useState();
17
+ const [usageStale, setUsageStale] = useState(false);
21
18
  const [authorizeUrl, setAuthorizeUrl] = useState();
22
19
  const [error, setError] = useState();
23
20
  const [loading, setLoading] = useState(false);
@@ -43,12 +40,17 @@ export function CursorCard({ rpc, t }) {
43
40
  const refreshUsage = useCallback(async () => {
44
41
  setLoading(true);
45
42
  try {
46
- setUsage(await callSubscriptionsAuth(rpc, 'cursorUsage', {}));
43
+ const value = await callSubscriptionsAuth(rpc, 'cursorUsage', {});
44
+ setUsage(value);
45
+ // Prefer the server's observation time; a cached reading must not look fresh.
46
+ setObservedAt(value.observedAt ?? Date.now());
47
+ setUsageStale(value.stale === true);
47
48
  setError(undefined);
48
49
  window.dispatchEvent(new Event(USAGE_BADGE_REFRESH_EVENT));
49
50
  }
50
51
  catch (failure) {
51
52
  setError(message(failure));
53
+ setUsageStale(true);
52
54
  }
53
55
  finally {
54
56
  setLoading(false);
@@ -91,6 +93,8 @@ export function CursorCard({ rpc, t }) {
91
93
  try {
92
94
  await callSubscriptionsAuth(rpc, 'cursorLogout', {});
93
95
  setUsage(undefined);
96
+ setObservedAt(undefined);
97
+ setUsageStale(false);
94
98
  await refreshStatus();
95
99
  }
96
100
  catch (failure) {
@@ -101,13 +105,13 @@ export function CursorCard({ rpc, t }) {
101
105
  return _jsxs("div", { style: styles.card, children: [_jsxs("div", { style: styles.header, children: [_jsx("span", { style: { ...styles.dot, background: status?.busy ? 'var(--dsw-alias-state-warn-label)'
102
106
  : connected ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-dimmed)' } }), _jsx("span", { style: styles.name, children: t('cursorTitle') })] }), _jsx("p", { style: styles.status, children: status === undefined ? t('checking') : status.busy ? t('loginInProgress')
103
107
  : connected ? t('loggedInCount', { count: 1 }) : t('notLoggedIn') }), error !== undefined && _jsx("p", { style: styles.error, children: error }), connected && _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: "Cursor" }), status.expiresAt !== undefined && _jsx("span", { style: styles.status, children: t('accountExpires', { date: new Date(status.expiresAt).toLocaleString() }) }), _jsx("button", { type: "button", style: { ...styles.button, marginLeft: 'auto', flexShrink: 0 }, onClick: () => { void logout(); }, children: t('logout') })] }), _jsxs("div", { style: styles.usage, children: [_jsxs("div", { style: styles.usageHeader, children: [_jsx("span", { style: styles.usageTitle, children: t('usageTitle') }), usage?.plan !== undefined && _jsx("span", { style: styles.status, children: t('usagePlan', { plan: usage.plan }) }), _jsx("button", { type: "button", style: styles.usageRefresh, disabled: loading, onClick: () => { void refreshUsage(); }, children: t('usageRefresh') })] }), loading && usage === undefined && _jsx("p", { style: styles.status, children: t('usageLoading') }), usage?.windows?.length === 0 && _jsx("p", { style: styles.status, children: t('usageEmpty') }), usage?.windows?.map((window, index) => {
104
- const percent = Math.min(100, Math.max(0, window.usedPercent));
108
+ const percent = displayUsedPercent(window.usedPercent);
105
109
  const label = window.scope === 'Included' ? t('cursorIncluded')
106
110
  : window.scope === 'Cursor Models' ? t('cursorModels')
107
111
  : window.scope === 'Other Models' ? t('cursorOtherModels')
108
112
  : window.scope === 'Included requests' ? t('cursorIncludedRequests')
109
113
  : window.scope ?? t('usageWindow');
110
- return _jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: label }), _jsxs("span", { children: [Math.round(percent), "%", window.resetsAt === undefined ? ''
111
- : ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { width: `${percent}%`, height: '100%', borderRadius: 3, background: barColor(percent) } }) })] }, index);
114
+ 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 ? ''
115
+ : ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx(UsageMeter, { window: window, t: t, observedAt: observedAt, stale: usageStale })] }, index);
112
116
  })] })] }), _jsxs("div", { style: styles.actions, children: [status?.authenticated === false && !status.busy && _jsx("button", { type: "button", style: styles.button, onClick: () => { void login(); }, children: t('login') }), _jsx("button", { type: "button", style: styles.button, "aria-haspopup": "dialog", onClick: () => setManage(true), children: t('accountsManage') }), status?.busy === true && _jsx("button", { type: "button", style: styles.button, onClick: () => { void cancel(); }, children: t('cancel') })] }), status?.busy === true && authorizeUrl !== undefined && _jsx("a", { href: authorizeUrl, target: "_blank", rel: "noopener noreferrer", children: t('cursorOpenLogin') }), manage && _jsx(ProviderAccountManager, { provider: "cursor-subscription", name: "Cursor", rpc: rpc, t: t, onClose: () => setManage(false) })] });
113
117
  }
@@ -3,33 +3,33 @@ import { useCallback, useEffect, useState } from 'react';
3
3
  import { callSubscriptionsAuth } from './subscriptions-rpc.js';
4
4
  import { subscriptionCardStyles as styles } from './subscription-card-styles.js';
5
5
  import { USAGE_BADGE_REFRESH_EVENT } from './usage-badge-preferences.js';
6
+ import { UsageMeter } from './UsageMeter.js';
7
+ import { displayUsedPercent } from './usage-pace.js';
6
8
  const SOURCES = [
7
9
  { id: 'opencode-go', name: 'OpenCode Go', ref: 'OPENCODE_GO_API_KEY' },
8
10
  { id: 'kimi-code', name: 'Kimi Code', ref: 'KIMI_CODING_API_KEY' },
11
+ { id: 'minimax', name: 'MiniMax', ref: 'MINIMAX_API_KEY' },
12
+ { id: 'minimax-cn', name: 'MiniMax CN', ref: 'MINIMAX_CN_API_KEY' },
9
13
  ];
10
14
  function errorText(error) {
11
15
  return error instanceof Error ? error.message : String(error);
12
16
  }
13
- function barColor(percent) {
14
- if (percent >= 95)
15
- return 'var(--dsw-alias-state-error-primary)';
16
- if (percent >= 80)
17
- return 'var(--dsw-alias-state-warn-label)';
18
- return 'var(--dsw-alias-state-success-primary)';
19
- }
20
17
  /** Quota cards for API-key providers already available through DSH itself. */
21
18
  export function ExternalUsageCards({ rpc, t }) {
22
19
  const [status, setStatus] = useState();
23
20
  const [statusError, setStatusError] = useState();
24
21
  const [usage, setUsage] = useState({});
22
+ const [observedAt, setObservedAt] = useState({});
25
23
  const [errors, setErrors] = useState({});
26
24
  const [loading, setLoading] = useState({});
27
25
  const refresh = useCallback(async (source) => {
28
26
  setLoading(prev => ({ ...prev, [source]: true }));
29
- setErrors(prev => ({ ...prev, [source]: undefined }));
30
27
  try {
31
28
  const value = await callSubscriptionsAuth(rpc, 'externalUsage', { source });
32
29
  setUsage(prev => ({ ...prev, [source]: value }));
30
+ // Prefer the server's observation time; a cached reading must not look fresh.
31
+ setObservedAt(prev => ({ ...prev, [source]: value.observedAt ?? Date.now() }));
32
+ setErrors(prev => ({ ...prev, [source]: undefined }));
33
33
  window.dispatchEvent(new Event(USAGE_BADGE_REFRESH_EVENT));
34
34
  }
35
35
  catch (error) {
@@ -61,12 +61,12 @@ export function ExternalUsageCards({ rpc, t }) {
61
61
  return _jsxs("div", { style: styles.card, children: [_jsxs("div", { style: styles.header, children: [_jsx("span", { style: { ...styles.dot, background: configured
62
62
  ? '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
63
  : 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
- const percent = Math.min(100, Math.max(0, window.usedPercent));
64
+ const percent = displayUsedPercent(window.usedPercent);
65
65
  const label = window.kind === 'session' ? t('usageSession')
66
66
  : window.kind === 'weekly' ? t('usageWeekly')
67
67
  : window.scope === 'Monthly' ? t('usageMonthly') : window.scope ?? t('usageWindow');
68
- return _jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: label }), _jsxs("span", { children: [Math.round(percent), "%", window.resetsAt === undefined ? ''
69
- : ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${percent}%`, background: barColor(percent) } }) })] }, index);
68
+ 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
+ : ` · ${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
70
  })] })] }), !configured && _jsx("div", { style: styles.actions, children: _jsx("button", { type: "button", style: styles.button, onClick: () => { void refreshStatus(); }, children: t('usageRefresh') }) })] }, id);
71
71
  }) });
72
72
  }
@@ -31,6 +31,8 @@ export interface AccountUsageDisplay {
31
31
  /** The account direct routes serve; the collapsed pill reads this one. */
32
32
  isDefault: boolean;
33
33
  windows: UsageWindow[];
34
+ observedAt?: number | undefined;
35
+ stale?: boolean | undefined;
34
36
  }
35
37
  /** One provider's usage snapshot: every logged-in account that reports windows. */
36
38
  export interface ProviderUsageDisplay {
@@ -42,7 +44,7 @@ export interface ProviderUsageDisplay {
42
44
  /** The account the collapsed pill reads: the default one, else the first listed. */
43
45
  export declare function pillAccountOf(d: ProviderUsageDisplay): AccountUsageDisplay;
44
46
  /** Brand display names (short form for the compact badge). */
45
- export type BadgeProvider = SubscriptionProvider | 'cursor-subscription' | 'opencode-go' | 'kimi-coding';
47
+ export type BadgeProvider = SubscriptionProvider | 'cursor-subscription' | 'opencode-go' | 'kimi-coding' | 'minimax' | 'minimax-cn';
46
48
  export interface UsageRosterEntry {
47
49
  provider: BadgeProvider;
48
50
  account: AccountStatus;
@@ -107,10 +109,12 @@ type Translate = (key: SubscriptionsKey, params?: Record<string, unknown>) => st
107
109
  */
108
110
  export declare function SubscriptionUsageBadge({ rpc, currentModel, t }: SubscriptionUsageBadgeProps): import("react").JSX.Element;
109
111
  /** Preview each account independently; all remaining quotas stay accessible. */
110
- export declare function AccountWindows({ windows, model, provider, translate }: {
112
+ export declare function AccountWindows({ windows, model, provider, translate, observedAt, stale }: {
111
113
  windows: readonly UsageWindow[];
112
114
  model: string | undefined;
113
115
  provider?: BadgeProvider;
114
116
  translate: Translate;
117
+ observedAt?: number | undefined;
118
+ stale?: boolean | undefined;
115
119
  }): import("react").JSX.Element;
116
120
  export {};
@@ -18,15 +18,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
18
18
  * The collapsed pill reads only the default account — the same account
19
19
  * direct (non-pool) routes serve — so it stays one short segment even for a
20
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
21
+ * Every color resolves through a `--dsw-*` design token, the dialog wears the
22
+ * host's menu material (its translucent fill plus its backdrop blur), and
23
+ * every user-visible string goes through the locale `t` of the
23
24
  * 'settings.subscriptions' namespace.
24
25
  */
25
26
  import { useCallback, useEffect, useRef, useState } from 'react';
26
27
  import { createPortal } from 'react-dom';
27
28
  import * as primitives from '@deepseek-ai/dsh-client-ui-primitives';
28
29
  import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives';
29
- import { callSubscriptionsAuth, usageBarColor } from './SubscriptionsSection.js';
30
+ import { callSubscriptionsAuth } from './SubscriptionsSection.js';
31
+ import { UsageMeter } from './UsageMeter.js';
32
+ import { displayUsedPercent } from './usage-pace.js';
30
33
  import { en } from './locales.js';
31
34
  import { hostIcon } from './host-icons.js';
32
35
  import { USAGE_BADGE_REFRESH_EVENT, useUsageBadgeMode } from './usage-badge-preferences.js';
@@ -56,6 +59,8 @@ const PROVIDER_NAMES = {
56
59
  'cursor-subscription': 'Cursor',
57
60
  'opencode-go': 'OpenCode Go',
58
61
  'kimi-coding': 'Kimi Code',
62
+ 'minimax': 'MiniMax',
63
+ 'minimax-cn': 'MiniMax CN',
59
64
  };
60
65
  /** Key of one account's cached windows: `provider:accountKey`. */
61
66
  function usageKeyOf(provider, account) {
@@ -119,7 +124,7 @@ export async function loadBadgeRoster(rpc) {
119
124
  }
120
125
  if (external.status === 'fulfilled') {
121
126
  for (const [source, provider] of [
122
- ['opencode-go', 'opencode-go'], ['kimi-code', 'kimi-coding'],
127
+ ['opencode-go', 'opencode-go'], ['kimi-code', 'kimi-coding'], ['minimax', 'minimax'], ['minimax-cn', 'minimax-cn'],
123
128
  ]) {
124
129
  refreshed.add(provider);
125
130
  if (external.value[source]?.configured)
@@ -133,7 +138,7 @@ export async function loadBadgeRoster(rpc) {
133
138
  export async function usageOf(rpc, { provider, account }) {
134
139
  if (provider === 'cursor-subscription')
135
140
  return callSubscriptionsAuth(rpc, 'cursorUsage', {});
136
- if (provider === 'opencode-go' || provider === 'kimi-coding') {
141
+ if (provider === 'opencode-go' || provider === 'kimi-coding' || provider === 'minimax' || provider === 'minimax-cn') {
137
142
  return callSubscriptionsAuth(rpc, 'externalUsage', { source: provider === 'kimi-coding' ? 'kimi-code' : provider });
138
143
  }
139
144
  return callSubscriptionsAuth(rpc, 'usage', { provider, account: account.key });
@@ -160,7 +165,7 @@ export function createCurrentModelReader(models, sessionId) {
160
165
  * Falls back to the scope/kind abbreviation when no reset time is known.
161
166
  */
162
167
  export function windowLabel(w) {
163
- if (w.resetsAt === undefined) {
168
+ if (w.resetsAt === undefined || !Number.isFinite(w.resetsAt)) {
164
169
  if (w.scope !== undefined && w.scope !== '')
165
170
  return w.scope;
166
171
  switch (w.kind) {
@@ -179,9 +184,13 @@ export function windowLabel(w) {
179
184
  return `${hours}h${minutes % 60}m`;
180
185
  return `${Math.max(1, minutes)}m`;
181
186
  }
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)));
187
+ /**
188
+ * One compact segment piece (`6d1h 25%`); an unusable provider percentage
189
+ * renders as the no-data dash, consistent with the meter's unavailable state.
190
+ */
191
+ function windowSegment(label, w) {
192
+ const percent = displayUsedPercent(w.usedPercent);
193
+ return percent === undefined ? `${label} —` : `${label} ${percent}%`;
185
194
  }
186
195
  /** Keep model quotas separate: matching percentages do not imply a shared pool. */
187
196
  export function prioritizeWindows(windows, model) {
@@ -210,10 +219,10 @@ export function compactSegment(d, model, t = fallbackTranslate) {
210
219
  count: new Set(windows.map(w => w.scope).filter(Boolean)).size,
211
220
  })}`;
212
221
  }
213
- const parts = matching.slice(0, 2).map(w => `${w.kind === 'weekly' ? t('usageWeekly') : t('usageWindow')} ${usedPercent(w)}%`);
222
+ const parts = matching.slice(0, 2).map(w => windowSegment(w.kind === 'weekly' ? t('usageWeekly') : t('usageWindow'), w));
214
223
  return `${d.name} ${parts.join(' · ')}`;
215
224
  }
216
- const parts = windows.slice(0, 2).map(w => `${windowLabel(w)} ${usedPercent(w)}%`);
225
+ const parts = windows.slice(0, 2).map(w => windowSegment(windowLabel(w), w));
217
226
  if (windows.length > 2)
218
227
  parts.push(`+${windows.length - 2}`);
219
228
  return `${d.name} ${parts.join(' · ')}`;
@@ -287,8 +296,10 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
287
296
  // across a failed poll (e.g. a 429 during the server's own negative-cache
288
297
  // cooldown) so a row doesn't flicker away — it only disappears once the
289
298
  // account actually logs out or a fetch succeeds but reports the window as
290
- // unsupported.
299
+ // unsupported. Observation metadata (freshness) shares this lifecycle: an
300
+ // entry is dropped wherever its windows are dropped.
291
301
  const lastKnownRef = useRef(new Map());
302
+ const observationsRef = useRef(new Map());
292
303
  const refresh = useCallback(async () => {
293
304
  if (rpc === undefined || inflightRef.current)
294
305
  return;
@@ -302,8 +313,10 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
302
313
  // real signal, unlike a fetch failure.
303
314
  const live = new Set(roster.map(({ provider, account }) => usageKeyOf(provider, account)));
304
315
  for (const key of lastKnown.keys()) {
305
- if (refreshed.has(key.split(':', 1)[0]) && !live.has(key))
316
+ if (refreshed.has(key.split(':', 1)[0]) && !live.has(key)) {
306
317
  lastKnown.delete(key);
318
+ observationsRef.current.delete(key);
319
+ }
307
320
  }
308
321
  const results = await Promise.allSettled(roster.map(async ({ provider, account }) => {
309
322
  const usage = await usageOf(rpc, { provider, account });
@@ -311,6 +324,10 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
311
324
  }));
312
325
  if (!mountedRef.current)
313
326
  return;
327
+ for (const { provider, account } of roster) {
328
+ const key = usageKeyOf(provider, account);
329
+ observationsRef.current.set(key, { ...observationsRef.current.get(key), stale: true });
330
+ }
314
331
  const plans = new Map();
315
332
  for (const r of results) {
316
333
  if (r.status !== 'fulfilled')
@@ -321,17 +338,29 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
321
338
  plans.set(key, usage.plan);
322
339
  if (!usage.supported || !usage.windows || usage.windows.length === 0) {
323
340
  lastKnown.delete(key);
341
+ observationsRef.current.delete(key);
324
342
  continue;
325
343
  }
326
344
  lastKnown.set(key, usage.windows);
345
+ observationsRef.current.set(key, { observedAt: usage.observedAt ?? Date.now(), stale: usage.stale === true });
327
346
  }
328
347
  setDisplays(previous => [
329
- ...groupUsageDisplays(roster, lastKnown, plans),
330
- ...previous.filter(display => !refreshed.has(display.provider)),
348
+ ...groupUsageDisplays(roster, lastKnown, plans).map(display => ({ ...display,
349
+ accounts: display.accounts.map(account => ({ ...account,
350
+ ...observationsRef.current.get(`${display.provider}:${account.key}`),
351
+ })),
352
+ })),
353
+ ...previous.filter(display => !refreshed.has(display.provider)).map(display => ({ ...display,
354
+ accounts: display.accounts.map(account => ({ ...account, stale: true })),
355
+ })),
331
356
  ]);
332
357
  }
333
358
  catch {
334
- // A failed poll must not crash the badge; keep last known state.
359
+ // Keep cached numbers, but never present failed observations as current.
360
+ if (mountedRef.current)
361
+ setDisplays(previous => previous.map(display => ({ ...display,
362
+ accounts: display.accounts.map(account => ({ ...account, stale: true })),
363
+ })));
335
364
  }
336
365
  finally {
337
366
  inflightRef.current = false;
@@ -429,7 +458,7 @@ export function SubscriptionUsageBadge({ rpc, currentModel, t }) {
429
458
  if (next)
430
459
  void refresh();
431
460
  };
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)] }));
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)] }));
433
462
  return (_jsxs(_Fragment, { children: [seat, statsRow !== null && statsRow.isConnected ? createPortal(pill, statsRow) : pill] }));
434
463
  }
435
464
  /**
@@ -457,15 +486,15 @@ function AccountMeta({ account, translate }) {
457
486
  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
487
  }
459
488
  /** Preview each account independently; all remaining quotas stay accessible. */
460
- export function AccountWindows({ windows, model, provider, translate }) {
489
+ export function AccountWindows({ windows, model, provider, translate, observedAt, stale }) {
461
490
  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))) }));
491
+ 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, t: translate, observedAt: observedAt, stale: stale }, i))) }));
463
492
  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
493
  }
465
494
  /** 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) } }) })] }));
495
+ function WindowRow({ label, window: w, t, observedAt, stale }) {
496
+ const percent = displayUsedPercent(w.usedPercent);
497
+ return (_jsxs(_Fragment, { children: [_jsx("dt", { style: styles.dt, children: label }), _jsxs("dd", { style: styles.dd, children: [percent === undefined ? t('usageMeterInvalid') : `${percent}%`, w.resetsAt !== undefined && Number.isFinite(w.resetsAt) && _jsxs("span", { style: styles.reset, children: [" \u00B7 ", windowLabel(w)] })] }), _jsx(UsageMeter, { window: w, t: t, style: styles.bar, observedAt: observedAt, stale: stale })] }));
469
498
  }
470
499
  /**
471
500
  * Unplaced portal panel: hidden but laid out so the clamp measures real
@@ -490,10 +519,14 @@ const styles = {
490
519
  color: 'var(--dsw-alias-label-secondary)',
491
520
  },
492
521
  label: { textOverflow: 'ellipsis', minWidth: 0, overflow: 'hidden' },
493
- // Mirrors the host stat-dialog panel.
522
+ // Mirrors the host stat-dialog panel. The menu fill is translucent by
523
+ // design and only the host's backdrop blur keeps it readable as a surface:
524
+ // without the blur the transcript behind the dialog stays sharp and shows
525
+ // through the fill.
494
526
  panel: {
495
527
  position: 'fixed', zIndex: 1100, boxSizing: 'border-box',
496
528
  background: 'var(--dsw-specific-menu)',
529
+ backdropFilter: 'var(--dsw-menu-backdrop-filter)',
497
530
  width: 'max-content', minWidth: 'min(300px, 100vw - 24px)', maxWidth: 'min(440px, 100vw - 24px)',
498
531
  maxHeight: 'min(560px, 100dvh - 24px)', overflowY: 'auto', overscrollBehavior: 'contain',
499
532
  boxShadow: 'var(--dsw-elevation-prominent)',
@@ -539,5 +572,4 @@ const styles = {
539
572
  gridColumn: '1 / -1', height: 4, borderRadius: 2, overflow: 'hidden',
540
573
  background: 'var(--dsw-alias-border-l2)', marginBottom: 2,
541
574
  },
542
- barFill: { height: '100%', borderRadius: 2 },
543
575
  };
@@ -30,9 +30,14 @@ export interface UsageWindow {
30
30
  scope?: string;
31
31
  usedPercent: number;
32
32
  resetsAt?: number;
33
+ startsAt?: number;
34
+ windowDurationMs?: number;
35
+ fixedWindow?: boolean;
33
36
  }
34
37
  /** `usage` endpoint value: the node half owns this shape. */
35
38
  export interface ProviderUsage {
39
+ observedAt?: number;
40
+ stale?: boolean;
36
41
  supported: boolean;
37
42
  windows?: UsageWindow[];
38
43
  plan?: string;
@@ -73,8 +78,8 @@ export type SubscriptionsSectionProps = Partial<SubscriptionsSectionInjected>;
73
78
  * fallback, never as a plan limit.
74
79
  */
75
80
  export declare function clientVersionText(t: SubscriptionsSectionInjected['t'], clientVersion: ProviderStatus['clientVersion']): string;
76
- /** Bar fill color: success normally, warn from 80%, error from 95%. Shared with the composer badge. */
77
- export declare function usageBarColor(usedPercent: number): string;
81
+ /** Meter fill color lives with the pace logic: fresh 90%+ is red, pace presets add a yellow lead threshold. */
82
+ export { usageBarColor } from './usage-pace.js';
78
83
  /** What one provider's collapsible default-effort section renders. */
79
84
  export interface ModelDefaultsView {
80
85
  /** Models with reasoning levels, after the name filter — one row each. */