dsh-plugin-subscription-oauth 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -126,7 +126,9 @@ const en = {
126
126
  detail_project: "Cloud project",
127
127
  lastError: "Last login attempt failed: {message}",
128
128
  loginFailed: "Could not start the sign-in: {message}",
129
- actionFailed: "Action failed: {message}"
129
+ actionFailed: "Action failed: {message}",
130
+ usageChipTitle: "{provider} subscription usage",
131
+ usageChipFull: "{provider} allowance"
130
132
  };
131
133
  /** Chinese strings. */
132
134
  const zh = {
@@ -218,7 +220,9 @@ const zh = {
218
220
  detail_project: "云项目",
219
221
  lastError: "上次登录失败:{message}",
220
222
  loginFailed: "无法开始登录:{message}",
221
- actionFailed: "操作失败:{message}"
223
+ actionFailed: "操作失败:{message}",
224
+ usageChipTitle: "{provider} 订阅用量",
225
+ usageChipFull: "{provider} 额度"
222
226
  };
223
227
 
224
228
  //#endregion
@@ -468,7 +472,13 @@ ${scope} .dsh-subscription-disclosure:hover:not(:disabled) {
468
472
  ${scope} .dsh-subscription-meter {
469
473
  height: 6px;
470
474
  border-radius: 999px;
471
- background: var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2));
475
+ /* The track must stay visible when nothing is spent — a 0 % window otherwise reads as
476
+ "no meter at all", which is the opposite of "nothing used". The layer token falls
477
+ back to the card's own colour on a host that does not define it, so the neutral is
478
+ explicit and translucent, and the inset ring keeps the empty track visible on a
479
+ light theme as well as a dark one. */
480
+ background: #8a8f9826;
481
+ box-shadow: inset 0 0 0 1px #8a8f9833;
472
482
  overflow: hidden;
473
483
  }
474
484
  ${scope} .dsh-subscription-meter > span {
@@ -480,6 +490,127 @@ ${scope} .dsh-subscription-meter > span[data-exhausted="true"] {
480
490
  background: var(--dsw-alias-state-error-primary, #b42318);
481
491
  }
482
492
  `;
493
+ /**
494
+ * The composer usage chip's stylesheet.
495
+ *
496
+ * Separate from the page's, because the chip renders in the composer's own bar rather
497
+ * than inside either page container — the page stylesheet is scoped to those
498
+ * containers on purpose, so nothing here may leak into the shell.
499
+ *
500
+ * The chip inherits the bar's type and only draws a meter, so it sits beside the shell's
501
+ * own stats pills without looking like a second design. The trigger is a bare control:
502
+ * the host gives plugin markup no control classes, and a default `<button>` would
503
+ * otherwise read as a raised chip. Every token carries a fallback for hosts that do not
504
+ * define it.
505
+ */
506
+ const usageChipCss = `
507
+ .dsh-subscription-usage-chip {
508
+ position: relative;
509
+ display: inline-flex;
510
+ align-items: center;
511
+ /* The bar is a centred flex row whose children are this slot's entries followed by the
512
+ shell's context meter, and a list slot renders its entries as bare children (no
513
+ wrapper). So the chip is itself a flex item: a higher order places it after the
514
+ context meter, and no auto margin is set, so it sits immediately beside it rather
515
+ than being pushed to the edge of the bar. */
516
+ order: 1;
517
+ min-width: 0;
518
+ font-family: inherit;
519
+ font-size: 12px;
520
+ line-height: 1;
521
+ }
522
+ .dsh-subscription-usage-chip-trigger {
523
+ display: inline-flex;
524
+ align-items: center;
525
+ gap: 6px;
526
+ margin: 0;
527
+ padding: 2px 6px;
528
+ border: 0;
529
+ border-radius: var(--dsw-radius-sm, 6px);
530
+ background: transparent;
531
+ color: var(--dsw-alias-label-secondary, #8a8f98);
532
+ font: inherit;
533
+ cursor: pointer;
534
+ }
535
+ .dsh-subscription-usage-chip-trigger:hover {
536
+ background: var(--dsw-alias-interactive-bg-hover, #ffffff14);
537
+ }
538
+ .dsh-subscription-usage-chip-trigger:focus-visible {
539
+ outline: 2px solid var(--dsw-alias-border-focus, #4d6bfe);
540
+ outline-offset: 1px;
541
+ }
542
+ .dsh-subscription-usage-chip-label {
543
+ overflow: hidden;
544
+ max-width: 6ch;
545
+ text-overflow: ellipsis;
546
+ white-space: nowrap;
547
+ }
548
+ .dsh-subscription-usage-chip-cell {
549
+ display: inline-flex;
550
+ align-items: baseline;
551
+ gap: 2px;
552
+ }
553
+ .dsh-subscription-usage-chip-window {
554
+ opacity: 0.75;
555
+ }
556
+ .dsh-subscription-usage-chip-value {
557
+ color: var(--dsw-alias-label-primary, #e8e8e8);
558
+ font-variant-numeric: tabular-nums;
559
+ }
560
+ .dsh-subscription-usage-chip-panel {
561
+ position: absolute;
562
+ bottom: calc(100% + 6px);
563
+ right: 0;
564
+ z-index: 20;
565
+ display: flex;
566
+ flex-direction: column;
567
+ gap: 8px;
568
+ min-width: 168px;
569
+ padding: 10px 12px;
570
+ border: 1px solid var(--dsw-alias-border-secondary, #ffffff1f);
571
+ border-radius: var(--dsw-radius-md, 10px);
572
+ background: var(--dsw-alias-bg-layer-2, #26262a);
573
+ box-shadow: var(--dsw-elevation-prominent, 0 12px 32px #0007);
574
+ color: var(--dsw-alias-label-primary, #e8e8e8);
575
+ white-space: nowrap;
576
+ }
577
+ .dsh-subscription-usage-chip-full {
578
+ font-size: 11px;
579
+ color: var(--dsw-alias-label-secondary, #8a8f98);
580
+ }
581
+ .dsh-subscription-usage-chip-meter {
582
+ display: flex;
583
+ flex-direction: column;
584
+ gap: 4px;
585
+ }
586
+ .dsh-subscription-usage-chip-meterHead {
587
+ display: flex;
588
+ align-items: baseline;
589
+ justify-content: space-between;
590
+ gap: 12px;
591
+ font-size: 12px;
592
+ }
593
+ .dsh-subscription-usage-chip-detail {
594
+ color: var(--dsw-alias-label-secondary, #8a8f98);
595
+ font-variant-numeric: tabular-nums;
596
+ }
597
+ .dsh-subscription-usage-chip-track {
598
+ display: block;
599
+ width: 100%;
600
+ height: 5px;
601
+ border-radius: 999px;
602
+ /* As on the settings meter: an unspent window has to show its track, so the neutral and
603
+ its ring are explicit rather than a layer token that may match the panel. */
604
+ background: #8a8f9826;
605
+ box-shadow: inset 0 0 0 1px #8a8f9833;
606
+ overflow: hidden;
607
+ }
608
+ .dsh-subscription-usage-chip-fill {
609
+ display: block;
610
+ height: 100%;
611
+ background: var(--dsw-alias-state-success-primary, #067647);
612
+ }
613
+ `;
483
614
 
484
615
  //#endregion
485
616
  //#region src/protocol.ts
@@ -501,6 +632,8 @@ const RPC_PREFIX = "subscription-oauth.";
501
632
  //#region src/client/subscriptions-rpc.ts
502
633
  /** The shared channel the node half's routes are mounted on. */
503
634
  const CHANNEL = "/api";
635
+ /** The host's own DeepSeek account controller, from which the chip reads a balance. */
636
+ const ACCOUNT_CHANNEL = "/api";
504
637
  /** A failed call: either the transport rejected or the endpoint answered `ok: false`. */
505
638
  var SubscriptionsRpcError = class extends Error {
506
639
  constructor(message, code) {
@@ -530,6 +663,55 @@ function createSubscriptionsCall(rpc) {
530
663
  return result.value;
531
664
  };
532
665
  }
666
+ /**
667
+ * Read the DeepSeek account's balance through the host's own account controller.
668
+ *
669
+ * The host owns the DeepSeek OAuth grant and the platform endpoint, so this asks it
670
+ * rather than re-deriving either. It returns `null` when the account is signed out or
671
+ * the read fails, so a chip can simply not render in that case.
672
+ *
673
+ * @param rpc - the browser connection's RPC caller.
674
+ * @returns the balance, or null when there is nothing to show.
675
+ */
676
+ function createAccountBalanceCall(rpc) {
677
+ return async function call() {
678
+ let result;
679
+ try {
680
+ result = await rpc.call(ACCOUNT_CHANNEL, "account/getBalance", {
681
+ version: "1",
682
+ locale: Intl.DateTimeFormat().resolvedOptions().locale ?? "en-US",
683
+ timezoneOffsetSeconds: -(/* @__PURE__ */ new Date()).getTimezoneOffset()
684
+ });
685
+ } catch (error) {
686
+ throw new SubscriptionsRpcError(messageOf$1(error), "RPC_TRANSPORT");
687
+ }
688
+ if (!result.ok) throw new SubscriptionsRpcError(result.error.message, result.error.code);
689
+ return result.value;
690
+ };
691
+ }
692
+ /**
693
+ * Read a manually added provider's balance through the plugin's cc-switch endpoint.
694
+ *
695
+ * The caller names the credential reference the provider's profile uses (its
696
+ * `apiKeyEnv`), and the host's credential service resolves it to the API key. The result
697
+ * is `undefined` when there is no key or the provider did not answer — never a wrong
698
+ * figure.
699
+ *
700
+ * @param rpc - the browser connection's RPC caller.
701
+ * @returns a narrow caller taking the credential reference.
702
+ */
703
+ function createCcSwitchBalanceCall(rpc) {
704
+ return async function call(credentialRef) {
705
+ let result;
706
+ try {
707
+ result = await rpc.call(CHANNEL, `${RPC_PREFIX}ccswitch-balance`, { credentialRef });
708
+ } catch (error) {
709
+ throw new SubscriptionsRpcError(messageOf$1(error), "RPC_TRANSPORT");
710
+ }
711
+ if (!result.ok) throw new SubscriptionsRpcError(result.error.message, result.error.code);
712
+ return result.value;
713
+ };
714
+ }
533
715
 
534
716
  //#endregion
535
717
  //#region src/client/SubscriptionsSection.tsx
@@ -586,7 +768,7 @@ const RESET_RELATIVE_MS = 1440 * 60 * 1e3;
586
768
  * @param t - translator, so the wording follows the page language.
587
769
  * @returns the reset text.
588
770
  */
589
- function formatReset(at, t) {
771
+ function formatReset$1(at, t) {
590
772
  const remaining = at - Date.now();
591
773
  if (remaining <= 0) return t("usageResetDue");
592
774
  if (remaining >= RESET_RELATIVE_MS) return t("usageReset", { time: formatTime(at) });
@@ -1051,7 +1233,7 @@ function UsageBlock(props) {
1051
1233
  ]
1052
1234
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1053
1235
  style: styles.modelMeta,
1054
- children: window$1.resetsAt === void 0 ? "" : formatReset(window$1.resetsAt, t)
1236
+ children: window$1.resetsAt === void 0 ? "" : formatReset$1(window$1.resetsAt, t)
1055
1237
  })]
1056
1238
  }), window$1.usedPercent !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1057
1239
  className: "dsh-subscription-meter",
@@ -2050,13 +2232,465 @@ function SubscriptionsSection(props) {
2050
2232
  });
2051
2233
  }
2052
2234
 
2235
+ //#endregion
2236
+ //#region src/client/UsageChip.tsx
2237
+ /** How often a visible chip refreshes its figures. */
2238
+ const REFRESH_INTERVAL_MS = 6e4;
2239
+ /**
2240
+ * Abbreviations for the routes this plugin serves.
2241
+ *
2242
+ * The bar is a status line under the composer, where a full product name crowds out the
2243
+ * shell's own pills; the full name is one click away. The fallback shortens an unknown
2244
+ * route's display name rather than dropping it.
2245
+ */
2246
+ const SHORT_LABELS = {
2247
+ kimi: "Kimi",
2248
+ grok: "Grok",
2249
+ antigravity: "Anti",
2250
+ glm: "GLM",
2251
+ ark: "Ark"
2252
+ };
2253
+ /**
2254
+ * Abbreviations for the window names the providers use.
2255
+ *
2256
+ * The bar has room for a token or two, so `weekly` becomes `7d` — the same figure in a
2257
+ * form that reads at a glance. An unrecognised window keeps its own name, shortened
2258
+ * only if it is long.
2259
+ */
2260
+ const SHORT_WINDOWS = {
2261
+ "weekly": "7d",
2262
+ "7d": "7d",
2263
+ "5h": "5h",
2264
+ "monthly": "mo",
2265
+ "daily": "1d",
2266
+ "credits": "cr",
2267
+ "free": "free",
2268
+ "session": "s"
2269
+ };
2270
+ /** The session's provider, from its model-selection projection. */
2271
+ function providerOf(projection) {
2272
+ return (projection?.next ?? projection?.lastUsed)?.provider;
2273
+ }
2274
+ /**
2275
+ * Reduce the host's account-balance payload to the wallets the chip shows.
2276
+ *
2277
+ * Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
2278
+ * first and any promotional credit as a secondary figure.
2279
+ *
2280
+ * @param value - the host's ready balance, or undefined when there is none.
2281
+ * @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
2282
+ */
2283
+ function balanceOf(value) {
2284
+ if (value === null || value === void 0 || !("value" in value) || value.value === void 0) return void 0;
2285
+ const wallets = [...value.value.map((wallet) => ({
2286
+ currency: wallet.currency,
2287
+ balance: wallet.balance,
2288
+ bonus: false
2289
+ })), ...(value.bonusWallets ?? []).map((wallet) => ({
2290
+ currency: wallet.currency,
2291
+ balance: wallet.balance,
2292
+ bonus: true
2293
+ }))];
2294
+ return wallets.length === 0 ? void 0 : wallets;
2295
+ }
2296
+ /**
2297
+ * Format one wallet for the chip's compact line.
2298
+ *
2299
+ * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
2300
+ * the currency prefix keeps two currencies from being mistaken for one sum.
2301
+ *
2302
+ * @param wallet - the wallet to show.
2303
+ * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
2304
+ */
2305
+ function formatBalance(wallet) {
2306
+ const amount = Number.parseFloat(wallet.balance);
2307
+ const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
2308
+ const symbol = wallet.currency === "CNY" ? "¥" : wallet.currency === "USD" ? "$" : `${wallet.currency} `;
2309
+ return `${wallet.bonus ? "+" : ""}${symbol}${trimmed}`;
2310
+ }
2311
+ /**
2312
+ * The short form of a route's name.
2313
+ *
2314
+ * @param provider - the route id.
2315
+ * @param label - the route's own display name.
2316
+ * @returns an abbreviation that still identifies the route.
2317
+ */
2318
+ function shortLabel(provider, label) {
2319
+ const known = SHORT_LABELS[provider];
2320
+ if (known !== void 0) return known;
2321
+ const [first] = label.trim().split(/\s+/);
2322
+ return first === void 0 || first === "" ? provider : first;
2323
+ }
2324
+ /**
2325
+ * The short form of a window's name.
2326
+ *
2327
+ * @param label - the window's own name, e.g. `weekly`.
2328
+ * @returns an abbreviation, or the label itself when it is already short.
2329
+ */
2330
+ function shortWindow(label) {
2331
+ const known = SHORT_WINDOWS[label.toLowerCase()];
2332
+ if (known !== void 0) return known;
2333
+ return label.length <= 4 ? label : label.slice(0, 4);
2334
+ }
2335
+ /**
2336
+ * Pick the window a single line should show.
2337
+ *
2338
+ * A plan can meter several windows at once (a rolling five-hour one and a weekly one).
2339
+ * The most constrained is the one that will stop work soonest, so it earns the space;
2340
+ * ties fall to the earlier window, which is the order the providers state them in.
2341
+ *
2342
+ * @param windows - the route's metered windows.
2343
+ * @returns the window to show, or undefined when none states a figure.
2344
+ */
2345
+ function pickWindow(windows) {
2346
+ let best;
2347
+ for (const window$1 of windows) {
2348
+ if (window$1.usedPercent === void 0) continue;
2349
+ if (best?.usedPercent === void 0 || window$1.usedPercent > best.usedPercent) best = window$1;
2350
+ }
2351
+ return best;
2352
+ }
2353
+ /**
2354
+ * Reduce one route's usage to what the chip renders.
2355
+ *
2356
+ * @param provider - the route the figures came from.
2357
+ * @param label - the route's display name.
2358
+ * @param usage - the accounts the route answered for.
2359
+ * @returns the reading, or undefined when the route stated no figure.
2360
+ */
2361
+ function readingOf(provider, label, usage) {
2362
+ for (const entry of usage) {
2363
+ const window$1 = pickWindow(entry.windows);
2364
+ if (window$1 === void 0) continue;
2365
+ return {
2366
+ provider,
2367
+ label,
2368
+ windows: entry.windows.filter((candidate) => candidate.usedPercent !== void 0),
2369
+ window: window$1,
2370
+ ...entry.plan === void 0 ? {} : { plan: entry.plan }
2371
+ };
2372
+ }
2373
+ }
2374
+ /**
2375
+ * Format a window's reset the way the settings cards do.
2376
+ *
2377
+ * The rule is shared with the cards on purpose: a figure reading `6d20h` here and
2378
+ * something else there would be two answers to one question.
2379
+ *
2380
+ * @param at - epoch milliseconds, or undefined when the provider states none.
2381
+ * @param now - the instant to measure from.
2382
+ * @returns a short phrase, or undefined when there is no reset to show.
2383
+ */
2384
+ function formatReset(at, now) {
2385
+ if (at === void 0) return void 0;
2386
+ const remaining = at - now;
2387
+ if (remaining <= 0) return "now";
2388
+ const hours = remaining / 36e5;
2389
+ if (hours < 1) return `${String(Math.max(1, Math.round(remaining / 6e4)))}m`;
2390
+ if (hours < 24) return `${String(Math.round(hours))}h`;
2391
+ const days = Math.floor(hours / 24);
2392
+ return `${String(days)}d${String(Math.round(hours - days * 24))}h`;
2393
+ }
2394
+ /**
2395
+ * The composer's usage chip.
2396
+ *
2397
+ * @param props - injected RPC caller, copy, and the session's provider reader.
2398
+ * @returns the chip, or null when there is nothing true to say.
2399
+ */
2400
+ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, ccSwitchCredentialRef, useProjection }) {
2401
+ const [reading, setReading] = (0, react.useState)(void 0);
2402
+ const [balance, setBalance] = (0, react.useState)(void 0);
2403
+ const [ccBalance, setCcBalance] = (0, react.useState)(void 0);
2404
+ const [now, setNow] = (0, react.useState)(() => Date.now());
2405
+ const [open, setOpen] = (0, react.useState)(false);
2406
+ const lastOwn = (0, react.useRef)(void 0);
2407
+ const selected = useProjection("modelSelection", (value) => providerOf(value));
2408
+ const isDeepSeek = selected === "deepseek-account";
2409
+ const ccRef = selected === void 0 || isOwnProvider(selected) || isDeepSeek ? void 0 : ccSwitchCredentialRef(selected);
2410
+ (0, react.useEffect)(() => {
2411
+ if (ccRef === void 0) {
2412
+ setCcBalance(void 0);
2413
+ return;
2414
+ }
2415
+ let live = true;
2416
+ const refresh = () => {
2417
+ ccSwitchBalance(ccRef).then((value) => {
2418
+ if (live) setCcBalance(value === void 0 ? void 0 : {
2419
+ currency: value.currency,
2420
+ total: value.total
2421
+ });
2422
+ }).catch(() => {});
2423
+ };
2424
+ refresh();
2425
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
2426
+ return () => {
2427
+ live = false;
2428
+ clearInterval(timer);
2429
+ };
2430
+ }, [ccRef, ccSwitchBalance]);
2431
+ const load = (0, react.useCallback)(async (target) => {
2432
+ try {
2433
+ const result = await call("usage", { provider: target });
2434
+ return readingOf(target, result.status.providers.find((entry) => entry.provider === target)?.displayName ?? target, result.usage);
2435
+ } catch {
2436
+ return;
2437
+ }
2438
+ }, [call]);
2439
+ (0, react.useEffect)(() => {
2440
+ if (!isDeepSeek) {
2441
+ setBalance(void 0);
2442
+ return;
2443
+ }
2444
+ let live = true;
2445
+ const refresh = () => {
2446
+ accountBalance().then((value) => {
2447
+ if (live) setBalance(balanceOf(value));
2448
+ }).catch(() => {});
2449
+ };
2450
+ refresh();
2451
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
2452
+ return () => {
2453
+ live = false;
2454
+ clearInterval(timer);
2455
+ };
2456
+ }, [isDeepSeek, accountBalance]);
2457
+ (0, react.useEffect)(() => {
2458
+ const target = selected !== void 0 && isOwnProvider(selected) ? selected : lastOwn.current;
2459
+ if (target === void 0) {
2460
+ setReading(void 0);
2461
+ return;
2462
+ }
2463
+ let live = true;
2464
+ const refresh = () => {
2465
+ setNow(Date.now());
2466
+ load(target).then((next) => {
2467
+ if (!live || next === void 0) return;
2468
+ lastOwn.current = target;
2469
+ setReading(next);
2470
+ });
2471
+ };
2472
+ refresh();
2473
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
2474
+ return () => {
2475
+ live = false;
2476
+ clearInterval(timer);
2477
+ };
2478
+ }, [
2479
+ selected,
2480
+ isOwnProvider,
2481
+ load
2482
+ ]);
2483
+ (0, react.useEffect)(() => {
2484
+ if (!open) return;
2485
+ const close = () => {
2486
+ setOpen(false);
2487
+ };
2488
+ document.addEventListener("click", close);
2489
+ return () => {
2490
+ document.removeEventListener("click", close);
2491
+ };
2492
+ }, [open]);
2493
+ if (ccRef !== void 0) {
2494
+ if (ccBalance === void 0) return null;
2495
+ const short$1 = shortLabel(selected ?? "", selected ?? "");
2496
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2497
+ className: "dsh-subscription-usage-chip",
2498
+ children: [
2499
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: usageChipCss }),
2500
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2501
+ type: "button",
2502
+ className: "dsh-subscription-usage-chip-trigger",
2503
+ "aria-expanded": open,
2504
+ title: t("usageChipTitle", { provider: selected ?? "" }),
2505
+ onClick: (event) => {
2506
+ event.stopPropagation();
2507
+ setOpen((was) => !was);
2508
+ },
2509
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2510
+ className: "dsh-subscription-usage-chip-label",
2511
+ children: short$1
2512
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2513
+ className: "dsh-subscription-usage-chip-cell",
2514
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2515
+ className: "dsh-subscription-usage-chip-value",
2516
+ children: `${ccBalance.currency === "CNY" ? "¥" : ccBalance.currency === "USD" ? "$" : `${ccBalance.currency} `}${ccBalance.total}`
2517
+ })
2518
+ })]
2519
+ }),
2520
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2521
+ className: "dsh-subscription-usage-chip-panel",
2522
+ role: "status",
2523
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2524
+ className: "dsh-subscription-usage-chip-full",
2525
+ children: t("usageChipFull", { provider: selected ?? "" })
2526
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2527
+ className: "dsh-subscription-usage-chip-meter",
2528
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2529
+ className: "dsh-subscription-usage-chip-meterHead",
2530
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: ccBalance.currency }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2531
+ className: "dsh-subscription-usage-chip-detail",
2532
+ children: ccBalance.total
2533
+ })]
2534
+ })
2535
+ })]
2536
+ })
2537
+ ]
2538
+ });
2539
+ }
2540
+ if (isDeepSeek) {
2541
+ if (balance === void 0) return null;
2542
+ const short$1 = shortLabel("deepseek-account", "DeepSeek Account");
2543
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2544
+ className: "dsh-subscription-usage-chip",
2545
+ children: [
2546
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: usageChipCss }),
2547
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2548
+ type: "button",
2549
+ className: "dsh-subscription-usage-chip-trigger",
2550
+ "aria-expanded": open,
2551
+ title: t("usageChipTitle", { provider: "DeepSeek Account" }),
2552
+ onClick: (event) => {
2553
+ event.stopPropagation();
2554
+ setOpen((was) => !was);
2555
+ },
2556
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2557
+ className: "dsh-subscription-usage-chip-label",
2558
+ children: short$1
2559
+ }), balance.map((wallet) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2560
+ className: "dsh-subscription-usage-chip-cell",
2561
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2562
+ className: "dsh-subscription-usage-chip-value",
2563
+ children: formatBalance(wallet)
2564
+ })
2565
+ }, `${wallet.currency}${wallet.bonus ? ":bonus" : ""}`))]
2566
+ }),
2567
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2568
+ className: "dsh-subscription-usage-chip-panel",
2569
+ role: "status",
2570
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2571
+ className: "dsh-subscription-usage-chip-full",
2572
+ children: t("usageChipFull", { provider: "DeepSeek Account" })
2573
+ }), balance.map((wallet) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2574
+ className: "dsh-subscription-usage-chip-meter",
2575
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2576
+ className: "dsh-subscription-usage-chip-meterHead",
2577
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: `${wallet.bonus ? "Bonus · " : ""}${wallet.currency}` }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2578
+ className: "dsh-subscription-usage-chip-detail",
2579
+ children: formatBalance(wallet)
2580
+ })]
2581
+ })
2582
+ }, `${wallet.currency}${wallet.bonus ? ":bonus" : ""}`))]
2583
+ })
2584
+ ]
2585
+ });
2586
+ }
2587
+ if (reading === void 0) return null;
2588
+ if (reading.window.usedPercent === void 0) return null;
2589
+ formatReset(reading.window.resetsAt, now);
2590
+ const short = shortLabel(reading.provider, reading.label);
2591
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2592
+ className: "dsh-subscription-usage-chip",
2593
+ children: [
2594
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: usageChipCss }),
2595
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2596
+ type: "button",
2597
+ className: "dsh-subscription-usage-chip-trigger",
2598
+ "aria-expanded": open,
2599
+ title: t("usageChipTitle", { provider: reading.label }),
2600
+ onClick: (event) => {
2601
+ event.stopPropagation();
2602
+ setOpen((was) => !was);
2603
+ },
2604
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2605
+ className: "dsh-subscription-usage-chip-label",
2606
+ children: short
2607
+ }), reading.windows.map((window$1) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2608
+ className: "dsh-subscription-usage-chip-cell",
2609
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2610
+ className: "dsh-subscription-usage-chip-window",
2611
+ children: shortWindow(window$1.label)
2612
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2613
+ className: "dsh-subscription-usage-chip-value",
2614
+ children: `${String(Math.round(window$1.usedPercent ?? 0))}%`
2615
+ })]
2616
+ }, window$1.label))]
2617
+ }),
2618
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2619
+ className: "dsh-subscription-usage-chip-panel",
2620
+ role: "status",
2621
+ children: [
2622
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2623
+ className: "dsh-subscription-usage-chip-full",
2624
+ children: t("usageChipFull", { provider: reading.label })
2625
+ }),
2626
+ reading.windows.map((window$1) => {
2627
+ const windowReset = formatReset(window$1.resetsAt, now);
2628
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2629
+ className: "dsh-subscription-usage-chip-meter",
2630
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2631
+ className: "dsh-subscription-usage-chip-meterHead",
2632
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: window$1.label }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2633
+ className: "dsh-subscription-usage-chip-detail",
2634
+ children: `${String(Math.round(window$1.usedPercent ?? 0))}%${windowReset === void 0 ? "" : ` · ${windowReset}`}`
2635
+ })]
2636
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2637
+ className: "dsh-subscription-usage-chip-track",
2638
+ "aria-hidden": "true",
2639
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2640
+ className: "dsh-subscription-usage-chip-fill",
2641
+ style: { width: `${String(Math.max(0, Math.min(100, window$1.usedPercent ?? 0)))}%` }
2642
+ })
2643
+ })]
2644
+ }, window$1.label);
2645
+ }),
2646
+ reading.plan === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2647
+ className: "dsh-subscription-usage-chip-detail",
2648
+ children: reading.plan
2649
+ })
2650
+ ]
2651
+ })
2652
+ ]
2653
+ });
2654
+ }
2655
+
2053
2656
  //#endregion
2054
2657
  //#region src/client/index.ts
2658
+ /**
2659
+ * Routes this plugin serves.
2660
+ *
2661
+ * The chip only reports for these; a provider somebody else registered gets no chip,
2662
+ * because this plugin has no way to read its allowance.
2663
+ */
2664
+ const OWN_PROVIDER_IDS = new Set([
2665
+ "kimi",
2666
+ "grok",
2667
+ "antigravity",
2668
+ "glm",
2669
+ "ark"
2670
+ ]);
2055
2671
  /** Dictionary namespace owned by this plugin. */
2056
2672
  const NS = "settings.subscriptions";
2057
2673
  /** Settings nav position: after the shipped sections, before plugin tooling. */
2058
2674
  const SECTION_ORDER = 92;
2059
2675
  /**
2676
+ * Composer-dock position: after the shell's own stats pills, which sit at 0.
2677
+ *
2678
+ * The shell owns that bar and registers `tok/s` and the cache-hit pill into it, so the
2679
+ * chip follows them rather than displacing them.
2680
+ */
2681
+ const CHIP_ORDER = 10;
2682
+ /**
2683
+ * The credential reference a manually added provider's balance query uses, by provider
2684
+ * id.
2685
+ *
2686
+ * A manually added provider is not one this plugin serves, so the only way to read its
2687
+ * balance is cc-switch's query, which needs the provider's API key. The provider's
2688
+ * profile names that key by reference (`apiKeyEnv`), and the host's credential service
2689
+ * resolves the name; this map is the one place a provider id is tied to that name. A
2690
+ * provider with no entry gets no balance.
2691
+ */
2692
+ const CC_SWITCH_CREDENTIAL_REFS = { huanling: "HUANLING_API_KEY" };
2693
+ /**
2060
2694
  * Required services: `slots` carries the registration seat, `connection` the
2061
2695
  * RPC caller, and `locale` the copy dictionaries.
2062
2696
  */
@@ -2091,6 +2725,19 @@ function apply(ctx) {
2091
2725
  t
2092
2726
  })
2093
2727
  }, SubscriptionsSection));
2728
+ ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
2729
+ name: "conversation.composer.dock",
2730
+ id: "subscription-usage",
2731
+ order: CHIP_ORDER,
2732
+ inject: () => ({
2733
+ call,
2734
+ t,
2735
+ isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider),
2736
+ accountBalance: createAccountBalanceCall(connection.rpc),
2737
+ ccSwitchBalance: createCcSwitchBalanceCall(connection.rpc),
2738
+ ccSwitchCredentialRef: (provider) => CC_SWITCH_CREDENTIAL_REFS[provider]
2739
+ })
2740
+ }, UsageChip));
2094
2741
  }
2095
2742
 
2096
2743
  //#endregion