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
@@ -18,6 +18,8 @@ import { en } from './locales.js';
18
18
  import { ProviderAccountManager } from './ProviderAccountManager.js';
19
19
  import { ExternalUsageCards } from './ExternalUsageCards.js';
20
20
  import { CursorCard } from './CursorCard.js';
21
+ import { UsageMeter } from './UsageMeter.js';
22
+ import { displayUsedPercent } from './usage-pace.js';
21
23
  import { UsageBadgeDisplaySetting } from './UsageBadgeDisplaySetting.js';
22
24
  import { USAGE_BADGE_REFRESH_EVENT } from './usage-badge-preferences.js';
23
25
  import { subscriptionCardStyles as cardStyles } from './subscription-card-styles.js';
@@ -102,8 +104,6 @@ const styles = {
102
104
  font: 'inherit', fontSize: 14, lineHeight: '20px', cursor: 'pointer',
103
105
  color: 'var(--dsw-alias-state-warn-label)',
104
106
  },
105
- usageTrack: cardStyles.usageTrack,
106
- usageFill: cardStyles.usageFill,
107
107
  deviceCode: {
108
108
  marginTop: 4, display: 'flex', flexDirection: 'column', gap: 6,
109
109
  border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 8,
@@ -183,14 +183,8 @@ function usageWindowLabel(t, window) {
183
183
  : window.kind === 'weekly' ? t('usageWeekly') : t('usageWindow');
184
184
  return window.scope !== undefined && window.scope !== '' ? `${base} · ${window.scope}` : base;
185
185
  }
186
- /** Bar fill color: success normally, warn from 80%, error from 95%. Shared with the composer badge. */
187
- export function usageBarColor(usedPercent) {
188
- if (usedPercent >= 95)
189
- return 'var(--dsw-alias-state-error-primary)';
190
- if (usedPercent >= 80)
191
- return 'var(--dsw-alias-state-warn-label)';
192
- return 'var(--dsw-alias-state-success-primary)';
193
- }
186
+ /** Meter fill color lives with the pace logic: fresh 90%+ is red, pace presets add a yellow lead threshold. */
187
+ export { usageBarColor } from './usage-pace.js';
194
188
  /**
195
189
  * Derive one provider's default-effort section from its catalog and filter.
196
190
  * Pure so the collapsed-header counts and the filter stay testable without a
@@ -268,6 +262,7 @@ export function SubscriptionsSection(props) {
268
262
  const [copiedCode, setCopiedCode] = useState(undefined);
269
263
  /** Usage snapshots keyed `${provider}:${accountKey}` — every account tracks its own windows. */
270
264
  const [usages, setUsages] = useState({});
265
+ const [observedAt, setObservedAt] = useState({});
271
266
  const [usageErrors, setUsageErrors] = useState({});
272
267
  const [usageLoading, setUsageLoading] = useState({});
273
268
  const mountedRef = useRef(true);
@@ -377,6 +372,7 @@ export function SubscriptionsSection(props) {
377
372
  if (!mountedRef.current)
378
373
  return;
379
374
  setUsages(prev => ({ ...prev, [key]: usage }));
375
+ setObservedAt(prev => ({ ...prev, [key]: usage.observedAt ?? Date.now() }));
380
376
  window.dispatchEvent(new Event(USAGE_BADGE_REFRESH_EVENT));
381
377
  setUsageErrors((prev) => {
382
378
  const next = { ...prev };
@@ -535,9 +531,9 @@ export function SubscriptionsSection(props) {
535
531
  if (!account.isDefault)
536
532
  void setDefault(id, account.key);
537
533
  }, children: account.isDefault ? '★' : '☆' }), _jsx("span", { style: styles.accountName, children: display }), account.plan !== undefined && (_jsx("span", { style: styles.usagePlan, children: account.plan })), account.expiresAt !== undefined && (_jsx("span", { style: styles.statusLine, children: t('accountExpires', { date: new Date(account.expiresAt).toLocaleString() }) })), _jsx("button", { type: "button", style: { ...styles.button, marginLeft: 'auto', flexShrink: 0 }, onClick: () => { void logout(id, account.key, display, name); }, children: t('logout') })] }), showUsage && (_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.usagePlan, children: t('usagePlan', { plan: usage.plan }) })), _jsx("button", { type: "button", style: { ...styles.usageRefresh, ...usageLoading[usageKey] === true ? { opacity: 0.5, cursor: 'default' } : {} }, disabled: usageLoading[usageKey] === true, onClick: () => { void loadUsage(id, account.key, true); }, children: t('usageRefresh') })] }), usage === undefined && usageError === undefined && (_jsx("p", { style: styles.statusLine, children: t('usageLoading') })), usageError !== undefined && (_jsx("p", { style: styles.errorLine, children: t('usageError', { message: usageError }) })), usage?.windows !== undefined && usage.windows.length === 0 && (_jsx("p", { style: styles.statusLine, children: t('usageEmpty') })), (usage?.windows ?? []).map((window, index) => {
538
- const percent = Math.min(100, Math.max(0, window.usedPercent));
539
- return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [`${String(Math.round(percent))}%`, window.resetsAt !== undefined
540
- && ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx("div", { style: styles.usageTrack, children: _jsx("div", { style: { ...styles.usageFill, width: `${String(percent)}%`, background: usageBarColor(percent) } }) })] }, index));
534
+ const percent = displayUsedPercent(window.usedPercent);
535
+ return (_jsxs("div", { style: styles.usageRow, children: [_jsxs("div", { style: styles.usageMeta, children: [_jsx("span", { children: usageWindowLabel(t, window) }), _jsxs("span", { children: [percent === undefined ? t('usageMeterInvalid') : `${percent}%`, window.resetsAt !== undefined
536
+ && ` · ${t('usageResets', { date: new Date(window.resetsAt).toLocaleString() })}`] })] }), _jsx(UsageMeter, { window: window, t: t, observedAt: observedAt[usageKey], stale: usageError !== undefined || usage.stale === true })] }, index));
541
537
  })] }))] }, account.key));
542
538
  }), _jsxs("div", { style: styles.actions, children: [!busy && accounts.length === 0 && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id); }, children: t('login') })), !busy && accounts.length > 0 && id === 'claude' && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id, 'oauth'); }, children: t('addAccountOAuth') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id, 'keychain'); }, children: t('addAccountKeychain') })] })), !busy && accounts.length > 0 && id !== 'claude' && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void login(id); }, children: t('addAccount') })), _jsx("button", { type: "button", style: styles.button, "aria-haspopup": "dialog", onClick: () => setManagedProvider({ id, name }), children: t('accountsManage') }), busy && (_jsx("button", { type: "button", style: styles.button, onClick: () => { void cancel(id); }, children: t('cancel') }))] }), !busy && accounts.length > 0 && (_jsx("p", { style: styles.statusLine, children: t('addAccountHint') })), busy && deviceCode !== undefined && (_jsxs("div", { style: styles.deviceCode, children: [_jsx("span", { style: styles.statusLine, children: t('deviceCodePrompt') }), _jsx("span", { style: styles.deviceCodeText, children: deviceCode.userCode }), _jsxs("div", { style: styles.actions, children: [_jsx("button", { type: "button", style: styles.button, onClick: () => { copyDeviceCode(id, deviceCode.userCode); }, children: copiedCode === id ? t('deviceCodeCopied') : t('deviceCodeCopy') }), _jsx("button", { type: "button", style: styles.button, onClick: () => { window.open(deviceCode.verificationUrl, '_blank', 'noopener'); }, children: t('deviceCodeOpenPage') })] })] })), busy && deviceCode === undefined && (_jsxs("details", { style: styles.manual, children: [_jsx("summary", { children: t('manualSummary') }), _jsxs("div", { style: styles.manualRow, children: [_jsx("input", { style: styles.manualInput, value: manualDrafts[id], placeholder: t('manualPlaceholder'), onChange: event => setManualDrafts(prev => ({ ...prev, [id]: event.target.value })) }), _jsx("button", { type: "button", style: styles.button, onClick: () => { void submitManual(id); }, children: t('submit') })] })] }))] }, id));
543
539
  }), _jsx(CursorCard, { rpc: rpc, t: t }), _jsx(ExternalUsageCards, { rpc: rpc, t: t }), managedProvider && _jsx(ProviderAccountManager, { provider: managedProvider.id, name: managedProvider.name, rpc: rpc, t: t, onClose: () => setManagedProvider(undefined) })] }));
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { setUsageBadgeMode, useUsageBadgeMode } from './usage-badge-preferences.js';
4
+ import { isUsageColorPreset, setUsageColorPreset, useUsageColorPreset } from './usage-color-preferences.js';
4
5
  /** Browser-local display preference; never changes provider or account settings. */
5
6
  export function UsageBadgeDisplaySetting({ t }) {
6
7
  const mode = useUsageBadgeMode();
8
+ const preset = useUsageColorPreset();
7
9
  const [failed, setFailed] = useState(false);
8
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 => {
9
11
  const value = event.currentTarget.value;
@@ -18,7 +20,22 @@ export function UsageBadgeDisplaySetting({ t }) {
18
20
  throw error;
19
21
  setFailed(true);
20
22
  }
21
- }, children: [_jsx("option", { value: "recent", children: t('usageBadgeDisplayRecent') }), _jsx("option", { value: "hidden", children: t('usageBadgeDisplayHidden') })] })] }), _jsx("p", { style: styles.hint, children: t('usageBadgeDisplayHint') }), failed && _jsx("p", { role: "alert", style: { ...styles.hint, color: 'var(--dsw-alias-state-error-primary)' }, children: t('usageBadgeDisplaySaveFailed') })] }));
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 => {
24
+ const value = event.currentTarget.value;
25
+ if (!isUsageColorPreset(value))
26
+ return;
27
+ try {
28
+ setUsageColorPreset(value);
29
+ setFailed(false);
30
+ }
31
+ catch (error) {
32
+ if (!(error instanceof DOMException) || !['SecurityError', 'QuotaExceededError'].includes(error.name))
33
+ throw error;
34
+ setFailed(true);
35
+ }
36
+ }, children: [_jsx("option", { value: "standard", children: t('usageColorStandard') }), _jsx("option", { value: "relaxed", children: t('usageColorRelaxed') }), _jsx("option", { value: "remaining", children: t('usageColorRemaining') })] })] }), _jsx("p", { style: styles.hint, children: t(preset === 'remaining' ? 'usageColorRemainingHint' : 'usageColorPaceHint', {
37
+ allowance: preset === 'relaxed' ? 15 : 10,
38
+ }) }), failed && _jsx("p", { role: "alert", style: { ...styles.hint, color: 'var(--dsw-alias-state-error-primary)' }, children: t('usageBadgeDisplaySaveFailed') })] }));
22
39
  }
23
40
  const styles = {
24
41
  card: {
@@ -0,0 +1,8 @@
1
+ import type { CSSProperties } from 'react';
2
+ import type { UsageWindow, SubscriptionsSectionInjected } from './SubscriptionsSection.js';
3
+ import type { UsageObservation } from './usage-pace.js';
4
+ export declare function UsageMeter({ window: quota, t, style, observedAt, stale }: {
5
+ window: UsageWindow;
6
+ t: SubscriptionsSectionInjected['t'];
7
+ style?: CSSProperties;
8
+ } & UsageObservation): import("react").JSX.Element;
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { elapsedPercent, isUsageFresh, resetCountdownParts, usageBarColor, usageColorState, validUsage } from './usage-pace.js';
4
+ import { useUsageColorPreset } from './usage-color-preferences.js';
5
+ /** Singular/plural locale keys per countdown unit (zh maps both to one string). */
6
+ const UNIT_KEYS = {
7
+ day: ['usageUnitDay', 'usageUnitDays'],
8
+ hour: ['usageUnitHour', 'usageUnitHours'],
9
+ minute: ['usageUnitMinute', 'usageUnitMinutes'],
10
+ };
11
+ export function UsageMeter({ window: quota, t, style, observedAt, stale }) {
12
+ const preset = useUsageColorPreset();
13
+ const [now, setNow] = useState(Date.now);
14
+ useEffect(() => {
15
+ const timer = setInterval(() => setNow(Date.now()), 60_000);
16
+ return () => clearInterval(timer);
17
+ }, []);
18
+ // Use render time for new readings, without trusting a future observation timestamp.
19
+ const current = Math.max(now, Date.now());
20
+ const valid = validUsage(quota.usedPercent);
21
+ const used = valid ? quota.usedPercent : 0;
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);
26
+ const label = [
27
+ valid ? t('usageMeterUsed', { used: Math.round(used), remaining: Math.round(100 - used) }) : t('usageMeterInvalid'),
28
+ // An invalid reading already states unavailability; the stale line would repeat it.
29
+ !valid ? '' : !fresh ? t('usageMeterStale') : state === 'red' ? t('usageMeterNearLimit')
30
+ : elapsed === undefined && preset !== 'remaining' ? t('usageMeterUnknownTime')
31
+ : state === 'yellow' ? t('usageMeterAhead') : t('usageMeterNoWarning'),
32
+ elapsed === undefined ? '' : t('usageMeterPace', { elapsed: Math.round(elapsed) }),
33
+ elapsed === undefined ? '' : lead > 0 ? t('usageMeterPaceAhead', { points: lead })
34
+ : lead < 0 ? t('usageMeterPaceBehind', { points: -lead }) : t('usageMeterPaceEven'),
35
+ quota.resetsAt !== undefined && quota.resetsAt > current ? t('usageMeterReset', {
36
+ duration: resetCountdownParts(quota.resetsAt - current)
37
+ .map(part => t(UNIT_KEYS[part.unit][part.count === 1 ? 0 : 1], { count: part.count }))
38
+ .join(' '),
39
+ }) : '',
40
+ ].filter(Boolean).join(' · ');
41
+ return _jsxs("div", { role: "img", "aria-label": label, title: label, "data-usage-color": state, style: {
42
+ height: 6, borderRadius: 3, overflow: 'hidden',
43
+ background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
44
+ ...style, position: 'relative',
45
+ }, 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: {
47
+ position: 'absolute', left: `clamp(0px, calc(${elapsed}% - 1px), calc(100% - 2px))`,
48
+ top: 0, bottom: 0, width: 2, background: 'var(--dsw-alias-label-primary)',
49
+ boxShadow: '0 0 0 1px var(--dsw-alias-bg-layer-1)',
50
+ } })] });
51
+ }
@@ -83,7 +83,7 @@ export declare const HOST_CONTRACT: {
83
83
  /** Design tokens the client styles with; the host theme must define each one. */
84
84
  readonly tokens: {
85
85
  readonly package: "@deepseek-ai/dsh-client-ui-theme";
86
- readonly names: readonly ["--dsw-alias-bg-layer-1", "--dsw-alias-bg-skeleton", "--dsw-alias-border-l2", "--dsw-alias-border-l2-darkmode-thin", "--dsw-alias-interactive-bg-hover", "--dsw-alias-interactive-bg-hover-solid", "--dsw-alias-label-dimmed", "--dsw-alias-label-primary", "--dsw-alias-label-secondary", "--dsw-alias-label-tertiary", "--dsw-alias-state-error-primary", "--dsw-alias-state-success-primary", "--dsw-alias-state-warn-label", "--dsw-elevation-prominent", "--dsw-specific-menu"];
86
+ readonly names: readonly ["--dsw-alias-bg-layer-1", "--dsw-alias-bg-skeleton", "--dsw-alias-border-l2", "--dsw-alias-border-l2-darkmode-thin", "--dsw-alias-interactive-bg-hover", "--dsw-alias-interactive-bg-hover-solid", "--dsw-alias-label-dimmed", "--dsw-alias-label-primary", "--dsw-alias-label-secondary", "--dsw-alias-label-tertiary", "--dsw-alias-state-error-primary", "--dsw-alias-state-success-primary", "--dsw-alias-state-warn-label", "--dsw-elevation-prominent", "--dsw-menu-backdrop-filter", "--dsw-specific-menu"];
87
87
  };
88
88
  };
89
89
  /** A glyph name listed in {@link HOST_CONTRACT}; hostIcon() accepts no other. */
@@ -95,6 +95,7 @@ export const HOST_CONTRACT = {
95
95
  '--dsw-alias-state-success-primary',
96
96
  '--dsw-alias-state-warn-label',
97
97
  '--dsw-elevation-prominent',
98
+ '--dsw-menu-backdrop-filter',
98
99
  '--dsw-specific-menu',
99
100
  ],
100
101
  },
@@ -79,6 +79,30 @@ export declare const en: {
79
79
  usageMonthly: string;
80
80
  usageWindow: string;
81
81
  usageResets: string;
82
+ usageMeterUsed: string;
83
+ usageMeterPace: string;
84
+ usageMeterPaceAhead: string;
85
+ usageMeterPaceBehind: string;
86
+ usageMeterPaceEven: string;
87
+ usageMeterInvalid: string;
88
+ usageMeterStale: string;
89
+ usageMeterNearLimit: string;
90
+ usageMeterAhead: string;
91
+ usageMeterNoWarning: string;
92
+ usageMeterUnknownTime: string;
93
+ usageMeterReset: string;
94
+ usageUnitDay: string;
95
+ usageUnitDays: string;
96
+ usageUnitHour: string;
97
+ usageUnitHours: string;
98
+ usageUnitMinute: string;
99
+ usageUnitMinutes: string;
100
+ usageColorLabel: string;
101
+ usageColorStandard: string;
102
+ usageColorRelaxed: string;
103
+ usageColorRemaining: string;
104
+ usageColorPaceHint: string;
105
+ usageColorRemainingHint: string;
82
106
  usagePlan: string;
83
107
  externalUsageTitle: string;
84
108
  externalUsageConnected: string;
@@ -226,6 +250,30 @@ export declare const zh: {
226
250
  usageMonthly: string;
227
251
  usageWindow: string;
228
252
  usageResets: string;
253
+ usageMeterUsed: string;
254
+ usageMeterPace: string;
255
+ usageMeterPaceAhead: string;
256
+ usageMeterPaceBehind: string;
257
+ usageMeterPaceEven: string;
258
+ usageMeterInvalid: string;
259
+ usageMeterStale: string;
260
+ usageMeterNearLimit: string;
261
+ usageMeterAhead: string;
262
+ usageMeterNoWarning: string;
263
+ usageMeterUnknownTime: string;
264
+ usageMeterReset: string;
265
+ usageUnitDay: string;
266
+ usageUnitDays: string;
267
+ usageUnitHour: string;
268
+ usageUnitHours: string;
269
+ usageUnitMinute: string;
270
+ usageUnitMinutes: string;
271
+ usageColorLabel: string;
272
+ usageColorStandard: string;
273
+ usageColorRelaxed: string;
274
+ usageColorRemaining: string;
275
+ usageColorPaceHint: string;
276
+ usageColorRemainingHint: string;
229
277
  usagePlan: string;
230
278
  externalUsageTitle: string;
231
279
  externalUsageConnected: string;
@@ -79,6 +79,30 @@ export const en = {
79
79
  usageMonthly: 'Monthly',
80
80
  usageWindow: 'Window',
81
81
  usageResets: 'resets {date}',
82
+ usageMeterUsed: 'Used {used}% · Remaining {remaining}%',
83
+ usageMeterPace: 'Time elapsed {elapsed}% (vertical marker)',
84
+ usageMeterPaceAhead: '{points} points ahead of pace',
85
+ usageMeterPaceBehind: '{points} points behind pace',
86
+ usageMeterPaceEven: 'On pace',
87
+ usageMeterInvalid: 'Quota unavailable',
88
+ usageMeterStale: 'Reading is stale or unavailable; refresh to confirm',
89
+ usageMeterNearLimit: 'Near exhaustion',
90
+ usageMeterAhead: 'Usage ahead of pace',
91
+ usageMeterNoWarning: 'No quota warning',
92
+ usageMeterUnknownTime: 'Time progress unknown',
93
+ usageMeterReset: 'Resets in {duration}',
94
+ usageUnitDay: '{count} day',
95
+ usageUnitDays: '{count} days',
96
+ usageUnitHour: '{count} hour',
97
+ usageUnitHours: '{count} hours',
98
+ usageUnitMinute: '{count} minute',
99
+ usageUnitMinutes: '{count} minutes',
100
+ usageColorLabel: 'Usage coloring',
101
+ usageColorStandard: 'Standard (default · 10-point lead)',
102
+ usageColorRelaxed: 'Relaxed (15-point lead)',
103
+ usageColorRemaining: 'Remaining quota only (no yellow)',
104
+ usageColorPaceHint: 'Yellow when usage leads elapsed time by {allowance} points; red at 90% used takes priority. Saved in this browser; only colors change.',
105
+ usageColorRemainingHint: 'Red at 90% used; no yellow pace warnings. Reliable time markers remain visible. Saved in this browser.',
82
106
  usagePlan: 'Plan: {plan}',
83
107
  externalUsageTitle: 'Built-in provider usage',
84
108
  externalUsageConnected: 'API key configured',
@@ -226,6 +250,30 @@ export const zh = {
226
250
  usageMonthly: '每月',
227
251
  usageWindow: '窗口',
228
252
  usageResets: '{date} 重置',
253
+ usageMeterUsed: '已用 {used}% · 剩余 {remaining}%',
254
+ usageMeterPace: '时间已过 {elapsed}%(竖线标记)',
255
+ usageMeterPaceAhead: '领先时间进度 {points} 个百分点',
256
+ usageMeterPaceBehind: '落后时间进度 {points} 个百分点',
257
+ usageMeterPaceEven: '与时间进度一致',
258
+ usageMeterInvalid: '额度数据不可用',
259
+ usageMeterStale: '读数已陈旧或不可用,请刷新确认',
260
+ usageMeterNearLimit: '额度接近耗尽',
261
+ usageMeterAhead: '用量领先时间进度',
262
+ usageMeterNoWarning: '未触发额度警告',
263
+ usageMeterUnknownTime: '时间进度未知',
264
+ usageMeterReset: '{duration}后重置',
265
+ usageUnitDay: '{count} 天',
266
+ usageUnitDays: '{count} 天',
267
+ usageUnitHour: '{count} 小时',
268
+ usageUnitHours: '{count} 小时',
269
+ usageUnitMinute: '{count} 分钟',
270
+ usageUnitMinutes: '{count} 分钟',
271
+ usageColorLabel: '用量配色',
272
+ usageColorStandard: '标准(默认 · 领先 10 个百分点)',
273
+ usageColorRelaxed: '宽松(领先 15 个百分点)',
274
+ usageColorRemaining: '仅剩余额度(不显示黄色)',
275
+ usageColorPaceHint: '用量领先时间进度 {allowance} 个百分点时变黄;已用达到 90% 时优先变红。自动保存到当前浏览器,仅改变配色。',
276
+ usageColorRemainingHint: '已用达到 90% 时变红,不显示黄色节奏警告。可靠的时间标记仍保留,自动保存到当前浏览器。',
229
277
  usagePlan: '计划:{plan}',
230
278
  externalUsageTitle: '内置供应商用量',
231
279
  externalUsageConnected: '已配置 API 密钥',
@@ -45,9 +45,4 @@ export const subscriptionCardStyles = {
45
45
  display: 'flex', justifyContent: 'space-between', gap: 8,
46
46
  fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)',
47
47
  },
48
- usageTrack: {
49
- height: 6, borderRadius: 3, overflow: 'hidden',
50
- background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)',
51
- },
52
- usageFill: { height: '100%', borderRadius: 3 },
53
48
  };
@@ -0,0 +1,6 @@
1
+ export type UsageColorPreset = 'standard' | 'relaxed' | 'remaining';
2
+ export declare function isUsageColorPreset(value: unknown): value is UsageColorPreset;
3
+ export declare function readUsageColorPreset(): UsageColorPreset;
4
+ export declare function setUsageColorPreset(value: UsageColorPreset): void;
5
+ export declare function subscribeUsageColorPreset(notify: () => void): () => void;
6
+ export declare function useUsageColorPreset(): UsageColorPreset;
@@ -0,0 +1,38 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ const KEY = 'dsh.subscriptions.usageColorPreset';
3
+ const EVENT = 'dsh:subscriptions:usage-color-preset';
4
+ export function isUsageColorPreset(value) {
5
+ return value === 'standard' || value === 'relaxed' || value === 'remaining';
6
+ }
7
+ export function readUsageColorPreset() {
8
+ if (typeof window === 'undefined')
9
+ return 'standard';
10
+ try {
11
+ const value = window.localStorage.getItem(KEY);
12
+ return isUsageColorPreset(value) ? value : 'standard';
13
+ }
14
+ catch (error) {
15
+ if (!(error instanceof DOMException) || error.name !== 'SecurityError')
16
+ throw error;
17
+ return 'standard';
18
+ }
19
+ }
20
+ export function setUsageColorPreset(value) {
21
+ if (!isUsageColorPreset(value))
22
+ throw new TypeError('Invalid usage coloring preset');
23
+ window.localStorage.setItem(KEY, value);
24
+ window.dispatchEvent(new Event(EVENT));
25
+ }
26
+ export function subscribeUsageColorPreset(notify) {
27
+ const storage = (event) => { if (event.key === KEY || event.key === null)
28
+ notify(); };
29
+ window.addEventListener(EVENT, notify);
30
+ window.addEventListener('storage', storage);
31
+ return () => {
32
+ window.removeEventListener(EVENT, notify);
33
+ window.removeEventListener('storage', storage);
34
+ };
35
+ }
36
+ export function useUsageColorPreset() {
37
+ return useSyncExternalStore(subscribeUsageColorPreset, readUsageColorPreset, () => 'standard');
38
+ }
@@ -0,0 +1,29 @@
1
+ import type { UsageWindow } from './SubscriptionsSection.js';
2
+ import type { UsageColorPreset } from './usage-color-preferences.js';
3
+ export interface UsageObservation {
4
+ observedAt?: number | undefined;
5
+ stale?: boolean | undefined;
6
+ }
7
+ export type UsageColorState = 'neutral' | 'red' | 'yellow' | 'green';
8
+ export declare const USAGE_FRESHNESS_MS: number;
9
+ export declare function validUsage(used: number): boolean;
10
+ /**
11
+ * Rounded share for display, or undefined when the provider's number is
12
+ * unusable. Callers must show the unavailable state rather than clamp an
13
+ * out-of-range reading into 0% or 100%.
14
+ */
15
+ export declare function displayUsedPercent(used: number): number | undefined;
16
+ export interface CountdownPart {
17
+ unit: 'day' | 'hour' | 'minute';
18
+ count: number;
19
+ }
20
+ /**
21
+ * Reset countdown as the two largest nonzero whole units, rounded up so a
22
+ * window never announces zero remaining time. Always at least one minute.
23
+ */
24
+ export declare function resetCountdownParts(ms: number): CountdownPart[];
25
+ export declare function isUsageFresh(window: UsageWindow, observation: UsageObservation, now?: number): boolean;
26
+ /** Only explicit intervals or provider-verified fixed durations support linear pace. */
27
+ export declare function elapsedPercent(window: UsageWindow, now?: number): number | undefined;
28
+ export declare function usageColorState(used: number, elapsed: number | undefined, preset: UsageColorPreset, fresh: boolean): UsageColorState;
29
+ export declare function usageBarColor(used: number, elapsed?: number, preset?: UsageColorPreset, fresh?: boolean): string;
@@ -0,0 +1,58 @@
1
+ export const USAGE_FRESHNESS_MS = 5 * 60_000;
2
+ export function validUsage(used) {
3
+ return Number.isFinite(used) && used >= 0 && used <= 100;
4
+ }
5
+ /**
6
+ * Rounded share for display, or undefined when the provider's number is
7
+ * unusable. Callers must show the unavailable state rather than clamp an
8
+ * out-of-range reading into 0% or 100%.
9
+ */
10
+ export function displayUsedPercent(used) {
11
+ return validUsage(used) ? Math.round(used) : undefined;
12
+ }
13
+ /**
14
+ * Reset countdown as the two largest nonzero whole units, rounded up so a
15
+ * window never announces zero remaining time. Always at least one minute.
16
+ */
17
+ export function resetCountdownParts(ms) {
18
+ const total = Math.max(1, Math.ceil(ms / 60_000));
19
+ const parts = [
20
+ { unit: 'day', count: Math.floor(total / 1440) },
21
+ { unit: 'hour', count: Math.floor((total % 1440) / 60) },
22
+ { unit: 'minute', count: total % 60 },
23
+ ];
24
+ return parts.filter(part => part.count > 0).slice(0, 2);
25
+ }
26
+ export function isUsageFresh(window, observation, now = Date.now()) {
27
+ return validUsage(window.usedPercent) && observation.stale !== true
28
+ && observation.observedAt !== undefined && Number.isFinite(observation.observedAt)
29
+ && observation.observedAt <= now && now - observation.observedAt <= USAGE_FRESHNESS_MS
30
+ && (window.resetsAt === undefined || Number.isFinite(window.resetsAt) && window.resetsAt > now);
31
+ }
32
+ /** Only explicit intervals or provider-verified fixed durations support linear pace. */
33
+ export function elapsedPercent(window, now = Date.now()) {
34
+ const end = window.resetsAt;
35
+ if (end === undefined || !Number.isFinite(end) || end <= now)
36
+ return undefined;
37
+ const duration = window.windowDurationMs;
38
+ const start = window.startsAt ?? (window.fixedWindow === true && duration !== undefined && Number.isFinite(duration) && duration > 0 ? end - duration : undefined);
39
+ if (start === undefined || !Number.isFinite(start) || start >= end || start > now)
40
+ return undefined;
41
+ return Math.min(100, Math.max(0, (now - start) / (end - start) * 100));
42
+ }
43
+ export function usageColorState(used, elapsed, preset, fresh) {
44
+ if (!validUsage(used))
45
+ return 'neutral';
46
+ if (used >= 90)
47
+ return 'red';
48
+ // Missing pace or stale observations retain the last reported absolute color.
49
+ if (!fresh || preset === 'remaining' || elapsed === undefined)
50
+ return 'green';
51
+ return used - elapsed >= (preset === 'relaxed' ? 15 : 10) ? 'yellow' : 'green';
52
+ }
53
+ export function usageBarColor(used, elapsed, preset = 'standard', fresh = true) {
54
+ const state = usageColorState(used, elapsed, preset, fresh);
55
+ return state === 'red' ? 'var(--dsw-alias-state-error-primary)'
56
+ : state === 'yellow' ? 'var(--dsw-alias-state-warn-label)'
57
+ : 'var(--dsw-alias-state-success-primary)';
58
+ }