dsh-connect-trae 2.8.0 → 3.0.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
@@ -6,6 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
+ let react_dom = require("react-dom");
9
10
  //#region src/status-paths.ts
10
11
  /** Plugin-owned usage endpoint consumed by its browser half. */
11
12
  const TRAE_USAGE_PATH = "/plugins/dsh-connect-trae/usage";
@@ -21,6 +22,17 @@ window.__ModuleLoader__.load({
21
22
  /** Plugin-owned local account rescan endpoint. */
22
23
  const TRAE_ACCOUNTS_REFRESH_PATH = "/plugins/dsh-connect-trae/accounts/refresh";
23
24
  /**
25
+ * General balance of EVERY account in a region, for the composer panel's table.
26
+ *
27
+ * Separate from the usage route on purpose: the usage document backs the
28
+ * 5-minute readout, and multiplying its upstream reads by the account count
29
+ * would charge a healthy account for something it never looks at — the same
30
+ * principle that keeps `creditAlternatives` firing only when a balance hits
31
+ * zero. This one is paid when the panel is actually opened, which is the only
32
+ * place the table is shown.
33
+ */
34
+ const TRAE_ACCOUNT_CREDITS_PATH = "/plugins/dsh-connect-trae/account-credits";
35
+ /**
24
36
  * Plugin-owned daily check-in claim endpoint. POST, loopback-only, and the only
25
37
  * route in this plugin that changes upstream account state.
26
38
  */
@@ -73,6 +85,24 @@ window.__ModuleLoader__.load({
73
85
  return out;
74
86
  }
75
87
  /**
88
+ * Read the per-region account selections out of a settings value.
89
+ *
90
+ * SHARED because `accounts` now has TWO writers: the card, and the composer
91
+ * panel's account table (2.14.0). Two writers of one field must use one merge
92
+ * rule, or they drift — and the rule is not obvious.
93
+ *
94
+ * Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
95
+ * live reference, which passes the `typeof === 'object'` check and would be
96
+ * returned as if it were the map. Every lookup on it is then `undefined`, and
97
+ * the merge (`{ ...configured, [region]: id }`) would spread a reference into
98
+ * `{get: <function>}` — dropping the other region's selection and leaking a
99
+ * function into the settings document.
100
+ */
101
+ function configuredAccountsOf(configured) {
102
+ const accounts = unwrapVolatileDeep(configured)?.accounts;
103
+ return typeof accounts === "object" && accounts !== null ? accounts : {};
104
+ }
105
+ /**
76
106
  * Build the next `regions` settings value for the card's save. The write
77
107
  * targets ONLY the signed-in account's region slot; every other region's slot
78
108
  * is carried over untouched, so switching accounts never clobbers the other
@@ -203,6 +233,10 @@ window.__ModuleLoader__.load({
203
233
  .dsm-btn-outline:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed);background:rgba(255,255,255,.04)}
204
234
  .dsm-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}
205
235
  .dsm-btn-primary:hover:not(:disabled){opacity:.9}
236
+ .dsm-trae-mainui-switch{display:flex;justify-content:flex-end;padding:0 2px 8px}
237
+ .dsm-trae-mainui-switch-label{display:inline-flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0);white-space:nowrap;cursor:pointer}
238
+ .dsm-trae-mainui-switch-label input{margin:0;accent-color:var(--dsw-alias-brand-primary,#5686fe)}
239
+ .dsm-trae-mainui-switch-label:has(input:disabled){opacity:.6;cursor:default}
206
240
  .dsm-trae-usage{display:flex;flex-direction:column;gap:16px;margin:0;padding:16px 0 4px}
207
241
  .dsm-trae-tabs{display:flex;gap:6px;padding:4px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:10px;background:var(--dsw-alias-bg-layer-3,#2a2c33)}
208
242
  .dsm-trae-tab-cell{display:flex;align-items:center;gap:2px;flex:1;min-width:0}
@@ -263,7 +297,12 @@ window.__ModuleLoader__.load({
263
297
  .dsm-trae-model-name-rate{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;font-weight:400;line-height:16px;flex:none}
264
298
  /* The two head actions sit together; the probe one SPENDS quota, so it carries
265
299
  the hint tooltip rather than looking like its read-only sibling. */
300
+ /* The sidebar-credit switch. Two instances share this style: the card's own
301
+ row at the top and the copy beside the model actions (2.9.3). Without it the
302
+ checkbox rendered unstyled and read as absent — the whole reason the switch
303
+ looked missing. */
266
304
  .dsm-trae-models-actions{display:flex;align-items:center;gap:8px;flex:none}
305
+ }
267
306
  .dsm-trae-usage-alternative{display:flex;align-items:flex-start;gap:10px;margin-top:10px;padding:10px 12px;border:1px solid var(--dsw-alias-state-warning-border,#7a5b12);border-radius:10px;background:var(--dsw-alias-state-warning-bg,#2a2313)}
268
307
  .dsm-trae-usage-alternative-text{margin:0;flex:1 1 auto;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#e6e6e6)}
269
308
  .dsm-trae-usage-switch-button{flex:0 0 auto;padding:5px 10px;border:1px solid var(--dsw-alias-border-l2,#3a3d45);border-radius:8px;background:var(--dsw-alias-bg-layer-3,#2c2f36);color:var(--dsw-alias-label-primary,#e6e6e6);font-size:12px;line-height:16px;cursor:pointer}
@@ -309,6 +348,129 @@ window.__ModuleLoader__.load({
309
348
  an error: it is Trae's one-per-device-per-day rule, and the day is simply
310
349
  spent. Rendering it in the error colour would read as a plugin fault. */
311
350
  .dsm-trae-checkin-note{margin:8px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#c6c9d0)}
351
+ `;
352
+ /**
353
+ * Composer-row readout styles, injected by the readout itself.
354
+ *
355
+ * SEPARATE from `TRAE_CARD_CSS`, and that is not tidiness: the card's CSS is
356
+ * only injected when the settings card module loads, so putting the composer's
357
+ * rules in it means the readout renders UNSTYLED in the composer toolbar —
358
+ * which is exactly what shipped in 2.13.0–2.13.4. The trigger fell back to the
359
+ * browser's default button box, and the panel, without `position: fixed`, had
360
+ * its inline left/top ignored and rendered nowhere useful.
361
+ *
362
+ * `tests/composer-styles.spec.tsx` now asserts that every class this component
363
+ * renders has a rule here, so a rule cannot go missing without a test failing.
364
+ */
365
+ const COMPOSER_POINTS_CSS = `
366
+ /* The readout is plain composer text — no border, no fill at rest. The padding
367
+ is only the click target, invisible until the pointer arrives. */
368
+ .dsm-trae-composer-points{display:inline-flex;align-items:center;min-width:0}
369
+ .dsm-trae-composer-points-trigger{
370
+ appearance:none;display:inline-flex;align-items:center;align-self:stretch;
371
+ padding:2px 6px;border:0;border-radius:6px;background:transparent;
372
+ /* EXPLICIT, not inherit. The composer row's own font and body colour are
373
+ noticeably larger and darker than the neighbouring controls — a credit
374
+ readout is secondary information and must not shout. 12px matches this
375
+ plugin's original line; label-tertiary matches the shell's own composer-row
376
+ control (ContextMeter), which is the sibling this reads alongside. */
377
+ font-size:12px;line-height:18px;
378
+ color:var(--dsw-alias-label-tertiary,#999);
379
+ white-space:nowrap;cursor:pointer;
380
+ font-variant-numeric:tabular-nums;
381
+ }
382
+ .dsm-trae-composer-points-trigger:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}
383
+ .dsm-trae-composer-points-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
384
+ /* The panel is portaled to document.body and positioned from the trigger, so it
385
+ MUST be fixed — otherwise the left/top it measures are ignored and it lands in
386
+ document flow. All material comes from shell tokens so it follows the theme. */
387
+ .dsm-trae-composer-panel{
388
+ position:fixed;z-index:1100;box-sizing:border-box;
389
+ width:min(248px,calc(100vw - 24px));padding:12px;
390
+ border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.28));
391
+ border-radius:var(--dsw-radius-lg,14px);
392
+ background:var(--dsw-specific-menu,Canvas);
393
+ box-shadow:var(--dsw-elevation-prominent,0 8px 32px rgba(0,0,0,.22));
394
+ backdrop-filter:var(--dsw-menu-backdrop-filter,none);
395
+ font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#444);
396
+ }
397
+ .dsm-trae-composer-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}
398
+ .dsm-trae-composer-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary,CanvasText)}
399
+ /* Self-contained: the dsm-btn primitive lives in TRAE_CARD_CSS, which the
400
+ composer never loads, so the refresh button must style itself. */
401
+ .dsm-trae-composer-panel-refresh{
402
+ appearance:none;flex:none;margin-left:auto;padding:3px 10px;
403
+ border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.34));
404
+ border-radius:7px;background:transparent;
405
+ color:var(--dsw-alias-label-secondary,#444);
406
+ font:inherit;font-size:11px;line-height:16px;cursor:pointer;
407
+ }
408
+ .dsm-trae-composer-panel-refresh:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,rgba(127,127,127,.6));color:var(--dsw-alias-label-primary,CanvasText)}
409
+ .dsm-trae-composer-panel-refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:1px}
410
+ .dsm-trae-composer-panel-refresh:disabled{opacity:.5;cursor:default}
411
+ /* The panel is a TABLE: one row per account in the region, one figure (the
412
+ general balance) per row, and a row click switches accounts.
413
+
414
+ Rhythm is deliberate: the header sits on a hairline, the switch button owns
415
+ ALL horizontal padding (and pulls back by the same amount so its label lines
416
+ up with the header), and every row is the same height. A misaligned first
417
+ column is what makes a two-column popover read as broken. */
418
+ .dsm-trae-composer-panel-table{
419
+ width:100%;border-collapse:collapse;
420
+ font-size:12px;line-height:18px;
421
+ }
422
+ .dsm-trae-composer-panel-table thead th{
423
+ padding:0 0 7px;font-weight:500;text-align:left;font-size:11px;
424
+ color:var(--dsw-alias-label-tertiary,GrayText);
425
+ border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));
426
+ }
427
+ .dsm-trae-composer-panel-num{
428
+ text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap;
429
+ }
430
+ .dsm-trae-composer-panel-table thead th.dsm-trae-composer-panel-num{padding-right:0}
431
+ .dsm-trae-composer-panel-table tbody th{
432
+ padding:0;font-weight:400;text-align:left;vertical-align:middle;
433
+ }
434
+ .dsm-trae-composer-panel-table tbody td{
435
+ padding:4px 0;vertical-align:middle;
436
+ color:var(--dsw-alias-label-primary,CanvasText);
437
+ }
438
+ /* The current account reads as current without shouting: a filled dot and a
439
+ slightly heavier weight. Disabled — pressing it would write a selection that
440
+ has not changed. */
441
+ .dsm-trae-composer-panel-table tbody tr.dsm-trae-composer-panel-row-current td{font-weight:600}
442
+ .dsm-trae-composer-panel-switch{
443
+ appearance:none;display:flex;align-items:center;gap:7px;
444
+ width:100%;padding:4px 6px;margin-left:-6px;
445
+ border:0;border-radius:6px;background:transparent;
446
+ color:inherit;font:inherit;font-size:12px;text-align:left;cursor:pointer;
447
+ white-space:nowrap;overflow:hidden;
448
+ }
449
+ .dsm-trae-composer-panel-switch>span:last-child{overflow:hidden;text-overflow:ellipsis}
450
+ /* Hover belongs to the switch, not the row: the number cell is not clickable,
451
+ so lighting it up would promise an action it cannot perform. */
452
+ .dsm-trae-composer-panel-switch:hover:not(:disabled){
453
+ background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));
454
+ }
455
+ .dsm-trae-composer-panel-switch:focus-visible{
456
+ outline:2px solid var(--dsw-alias-brand-primary,#5686fe);outline-offset:-1px;
457
+ }
458
+ .dsm-trae-composer-panel-switch:disabled{cursor:default}
459
+ .dsm-trae-composer-panel-dot{
460
+ flex:none;width:7px;height:7px;border-radius:50%;
461
+ background:var(--dsw-alias-label-dimmed,rgba(127,127,127,.5));
462
+ }
463
+ .dsm-trae-composer-panel-table tr.dsm-trae-composer-panel-row-current .dsm-trae-composer-panel-dot{
464
+ background:var(--dsw-state-success,#22a06b);
465
+ }
466
+ .dsm-trae-composer-panel-foot{
467
+ display:flex;justify-content:space-between;gap:8px;
468
+ margin-top:9px;padding-top:7px;
469
+ border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24));
470
+ font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,GrayText);
471
+ }
472
+ .dsm-trae-composer-panel-empty{margin:0;color:var(--dsw-alias-label-tertiary,GrayText)}
473
+ .dsm-trae-composer-panel-error{margin:8px 0 0;color:var(--dsw-alias-state-error-primary,#d92d20);font-size:11px;line-height:16px}
312
474
  `;
313
475
  //#endregion
314
476
  //#region src/client/TraeUsageCard.tsx
@@ -321,20 +483,6 @@ window.__ModuleLoader__.load({
321
483
  const POLL_INTERVAL_MS = 6e4;
322
484
  const TRAE_GITHUB_URL = "https://github.com/dingminhua/dsh-connect-trae";
323
485
  /**
324
- * Read the per-region account selections out of the settings snapshot.
325
- *
326
- * Deep-unwraps first: on DSH 0.1.7 a volatile field arrives as a `{get(): T}`
327
- * live reference, which passes the `typeof === 'object'` check below and would
328
- * be returned as if it were the map. Every lookup on it is then `undefined`,
329
- * and the card's merge (`{ ...configured, [region]: id }`) would spread a
330
- * reference into `{get: <function>}` — dropping the other region's selection
331
- * and leaking a function into the settings document.
332
- */
333
- function configuredAccountsOf(configured) {
334
- const accounts = unwrapVolatileDeep(configured)?.accounts;
335
- return typeof accounts === "object" && accounts !== null ? accounts : {};
336
- }
337
- /**
338
486
  * Write one settings field and confirm the value actually landed.
339
487
  *
340
488
  * `configForms.set()` resolving is NOT proof that anything was stored: a write
@@ -510,6 +658,8 @@ window.__ModuleLoader__.load({
510
658
  * the control would flip and then quietly snap back on the next snapshot.
511
659
  */
512
660
  const [writeError, setWriteError] = (0, react.useState)(void 0);
661
+ /** True while the composer-credit switch write is in flight. */
662
+ const [togglingCredits, setTogglingCredits] = (0, react.useState)(false);
513
663
  const mounted = (0, react.useRef)(true);
514
664
  (0, react.useEffect)(() => {
515
665
  mounted.current = true;
@@ -517,6 +667,24 @@ window.__ModuleLoader__.load({
517
667
  mounted.current = false;
518
668
  };
519
669
  }, []);
670
+ /**
671
+ * Toggle the composer credit readout (`showPointsInMainUi`). The card's
672
+ * checkbox is the ONLY writer of this field — the readout itself carries no
673
+ * control — so there is exactly one path that can change it, and the write is
674
+ * read-back-checked like every other settings write in this card.
675
+ */
676
+ const toggleShowPointsInMainUi = async (enabled) => {
677
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
678
+ setTogglingCredits(true);
679
+ setWriteError(void 0);
680
+ try {
681
+ await writeSettingsField(settingsScope, "showPointsInMainUi", enabled, (readBack) => unwrapVolatileDeep(readBack)?.showPointsInMainUi === enabled);
682
+ } catch (error) {
683
+ if (mounted.current) setWriteError(error instanceof Error ? error.message : t("row.requestFailed"));
684
+ } finally {
685
+ if (mounted.current) setTogglingCredits(false);
686
+ }
687
+ };
520
688
  (0, react.useEffect)(() => settingsScope?.subscribe(() => {
521
689
  setSettingsRevision((value) => value + 1);
522
690
  }), [settingsScope]);
@@ -1046,6 +1214,21 @@ window.__ModuleLoader__.load({
1046
1214
  role: "status",
1047
1215
  children: t("row.tabOffNotice")
1048
1216
  }),
1217
+ activeRegion !== "cn" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1218
+ className: "dsm-trae-mainui-switch",
1219
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1220
+ className: "dsm-trae-mainui-switch-label",
1221
+ title: t("row.showPointsInMainUiHint"),
1222
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1223
+ type: "checkbox",
1224
+ checked: settingsValue?.showPointsInMainUi === true,
1225
+ disabled: settingsScope?.getSnapshot().writable !== true || togglingCredits,
1226
+ onChange: (event) => {
1227
+ toggleShowPointsInMainUi(event.currentTarget.checked);
1228
+ }
1229
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("row.showPointsInMainUi") })]
1230
+ })
1231
+ }),
1049
1232
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1050
1233
  className: "dsm-trae-usage-account",
1051
1234
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -1464,6 +1647,547 @@ window.__ModuleLoader__.load({
1464
1647
  });
1465
1648
  }
1466
1649
  //#endregion
1650
+ //#region src/client/popover.ts
1651
+ /**
1652
+ * The two shell behaviours the composer credit panel needs: hanging the panel
1653
+ * off its trigger, and dismissing it on an outside press.
1654
+ *
1655
+ * Implemented locally rather than imported from
1656
+ * `@deepseek-ai/dsh-client-ui-primitives`. The shell DOES register that package
1657
+ * for plugin bundles, so importing it is legal — this is a deliberate choice,
1658
+ * not a workaround: the two behaviours are ~40 lines of DOM geometry, and owning
1659
+ * them keeps this component renderable in the Node test suite without mocking a
1660
+ * package that itself reaches `*.module.css` and further DSH dependencies.
1661
+ *
1662
+ * NOTE ON A MISDIAGNOSIS, kept because it is the kind of thing that gets
1663
+ * re-litigated: the 2.12.0/2.13.0 client load failure was first attributed to
1664
+ * importing the primitives package. It was not that. The real cause was
1665
+ * `import { createPortal } from 'react-dom'` being BUNDLED rather than
1666
+ * externalized — the inlined development build opens with
1667
+ * `process.env.NODE_ENV` and threw `ReferenceError: process is not defined` in
1668
+ * the browser. See `tsdown.config.ts` for the fix and
1669
+ * `tests/client-runtime-imports.spec.ts` for the rule that now prevents it.
1670
+ *
1671
+ * Behaviour is intentionally identical to the shell's implementations: the same
1672
+ * top/bottom placement, the same viewport clamping, the same re-measure on
1673
+ * scroll (capture, so nested scrollers count), resize and the panel's own size
1674
+ * changes, and the same "a pointerdown outside both the trigger and the panel
1675
+ * closes it" rule.
1676
+ */
1677
+ /**
1678
+ * Clearance to keep from the top of the viewport.
1679
+ *
1680
+ * The shell reserves a strip at the top of the frame (`--dsh-frame-top-clearance`)
1681
+ * for the window chrome, so a panel clamped to `margin` alone can still land
1682
+ * under it. Absent variable or no `data-fullscreen` distinction is handled the
1683
+ * same way the shell does: fall back to `min`.
1684
+ */
1685
+ function overlayTopMargin(min) {
1686
+ if (typeof document === "undefined") return min;
1687
+ const root = document.documentElement;
1688
+ const clearance = Number.parseFloat(getComputedStyle(root).getPropertyValue("--dsh-frame-top-clearance"));
1689
+ if (Number.isNaN(clearance)) return min;
1690
+ return Math.max(min, (root.hasAttribute("data-fullscreen") ? 0 : clearance) + 20);
1691
+ }
1692
+ /** Keep a `position: fixed` floating element anchored to a trigger. */
1693
+ function useAnchoredPosition(options) {
1694
+ const { open, anchorRef, panelRef, side = "bottom", align = "start", gap, margin } = options;
1695
+ const [position, setPosition] = (0, react.useState)(null);
1696
+ (0, react.useLayoutEffect)(() => {
1697
+ if (!open) {
1698
+ setPosition(null);
1699
+ return;
1700
+ }
1701
+ const place = () => {
1702
+ const rect = anchorRef.current?.getBoundingClientRect();
1703
+ if (rect === void 0) return;
1704
+ const panel = panelRef.current;
1705
+ const width = panel?.offsetWidth ?? 0;
1706
+ const height = panel?.offsetHeight ?? 0;
1707
+ let left = align === "end" ? rect.right - width : rect.left;
1708
+ let top = side === "top" ? rect.top - gap - height : rect.bottom + gap;
1709
+ if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin);
1710
+ if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin)), window.innerHeight - height - margin);
1711
+ setPosition({
1712
+ left,
1713
+ top
1714
+ });
1715
+ };
1716
+ place();
1717
+ window.addEventListener("scroll", place, true);
1718
+ window.addEventListener("resize", place);
1719
+ const panel = panelRef.current;
1720
+ let observer = null;
1721
+ if (typeof ResizeObserver !== "undefined" && panel !== null) {
1722
+ observer = new ResizeObserver(place);
1723
+ observer.observe(panel);
1724
+ }
1725
+ return () => {
1726
+ observer?.disconnect();
1727
+ window.removeEventListener("scroll", place, true);
1728
+ window.removeEventListener("resize", place);
1729
+ };
1730
+ }, [
1731
+ open,
1732
+ anchorRef,
1733
+ panelRef,
1734
+ side,
1735
+ align,
1736
+ gap,
1737
+ margin
1738
+ ]);
1739
+ return position;
1740
+ }
1741
+ /**
1742
+ * Close an open popover when a pointerdown lands outside it.
1743
+ *
1744
+ * `portal` counts as inside: the panel is portaled to `document.body`, so it is
1745
+ * not a descendant of `root` and a click on the panel itself would otherwise
1746
+ * read as "outside" and dismiss the popover the user is trying to use.
1747
+ */
1748
+ function useDismissOnOutsidePointer(root, open, setOpen, portal) {
1749
+ (0, react.useEffect)(() => {
1750
+ if (!open) return void 0;
1751
+ const closeOutside = (event) => {
1752
+ if (event.target instanceof Node && root.current?.contains(event.target) !== true && portal?.current?.contains(event.target) !== true) setOpen(false);
1753
+ };
1754
+ document.addEventListener("pointerdown", closeOutside);
1755
+ return () => {
1756
+ document.removeEventListener("pointerdown", closeOutside);
1757
+ };
1758
+ }, [
1759
+ root,
1760
+ open,
1761
+ setOpen,
1762
+ portal
1763
+ ]);
1764
+ }
1765
+ //#endregion
1766
+ //#region src/client/ComposerPoints.tsx
1767
+ /**
1768
+ * Credit readout for the composer tool row (`conversation.input.left`).
1769
+ *
1770
+ * Shows the bound CN account's general credit balance, but ONLY while the
1771
+ * session's selected model belongs to this plugin. That condition is the whole
1772
+ * point: the sidebar foot is a single shared row, so two connector plugins each
1773
+ * parking a line there end up fighting for the same space. The composer row is
1774
+ * per-session and provider-scoped, so exactly one plugin's credits are relevant
1775
+ * at a time and nothing overlaps.
1776
+ *
1777
+ * The row itself is a bare clickable readout — `Trae CN · 657` — with NO
1778
+ * refresh control. Clicking it opens an anchored panel: a TABLE with one row
1779
+ * per account in the region and that account's general balance, where a row
1780
+ * click switches to it. The manual refresh button lives in the panel too.
1781
+ *
1782
+ * Two deliberate choices in that panel:
1783
+ *
1784
+ * - GENERAL balance only, not Work credits. The table's one figure is the
1785
+ * bucket the SOLO chat actually spends; two columns of numbers in a
1786
+ * five-slot popover is noise, and the card is where the breakdown belongs.
1787
+ * - Per-account figures are fetched ONLY while the panel is open. The
1788
+ * 5-minute readout must not multiply its upstream reads by the account
1789
+ * count — a healthy balance should not pay for a table nobody is looking at.
1790
+ * This is the same principle that keeps `creditAlternatives` firing only
1791
+ * when a balance hits zero.
1792
+ *
1793
+ * The provider comes from the Host's own `modelSelection` projection rather
1794
+ * than from anything this plugin tracks: the projection is what the model
1795
+ * picker writes and what the next request will use, so the readout cannot
1796
+ * disagree with the model actually in effect.
1797
+ */
1798
+ /**
1799
+ * Inject the readout's styles into the document head, once, on module load.
1800
+ *
1801
+ * Same idempotent pattern as the card's injection, and for the same reason: the
1802
+ * classes are attached to DOM this component owns, and `.dsm-card-*` lives in
1803
+ * `TRAE_CARD_CSS` — which only loads when the settings card does. Before this
1804
+ * existed (2.13.0–2.13.4) the trigger rendered as the browser's default button
1805
+ * box and the panel, with no `position: fixed`, had its measured left/top
1806
+ * ignored and appeared nowhere.
1807
+ */
1808
+ if (typeof document !== "undefined") {
1809
+ const cssId = "dsh-connect-trae/composer-points.css";
1810
+ if (!document.querySelector(`style[data-plugin-css="${cssId}"]`)) {
1811
+ const styleTag = document.createElement("style");
1812
+ styleTag.dataset.plugin = "dsh-connect-trae";
1813
+ styleTag.dataset.pluginCss = cssId;
1814
+ styleTag.textContent = COMPOSER_POINTS_CSS;
1815
+ document.head.appendChild(styleTag);
1816
+ }
1817
+ }
1818
+ /**
1819
+ * Automatic refresh period. Five minutes, matching the sidebar line: a credit
1820
+ * balance moves on the scale of model calls, not seconds, and each read is two
1821
+ * read-only upstream calls.
1822
+ */
1823
+ const COMPOSER_POINTS_REFRESH_INTERVAL_MS = 3e5;
1824
+ /**
1825
+ * Provider routes whose credit balance this readout can show, mapped to the
1826
+ * region to read it from.
1827
+ *
1828
+ * CN ONLY, and the restriction is the data's, not a preference: the Host builds
1829
+ * the international region's usage document from `payStatus` and never sets
1830
+ * `credits` on it (`webStatusFor` returns early for `region === 'ai'`), because
1831
+ * that side is subscription-based and has no comparable balance. Mapping
1832
+ * `trae-global` here would render the hardcoded "Trae CN" label next to a dash
1833
+ * — a number-slot with no number — while an international model is selected.
1834
+ *
1835
+ * Mirrors the CN half of `TRAE_PROVIDERS` on the host side; duplicated here
1836
+ * because the client bundle must not import the host entry.
1837
+ */
1838
+ const TRAE_COMPOSER_PROVIDERS = { "trae": "cn" };
1839
+ /** The general credit balance out of one usage document, or undefined. */
1840
+ function generalCreditsOf(usage) {
1841
+ if (usage.status !== "signed-in") return void 0;
1842
+ return usage.credits?.generalAvailable;
1843
+ }
1844
+ /** Group the digits so a four-figure balance stays readable in a narrow row. */
1845
+ function formatCredits(value) {
1846
+ return value.toLocaleString("en-US", { maximumFractionDigits: 0 });
1847
+ }
1848
+ function formatClock(value) {
1849
+ return new Date(value).toLocaleTimeString("zh-CN", {
1850
+ hour: "2-digit",
1851
+ minute: "2-digit"
1852
+ });
1853
+ }
1854
+ function ComposerPoints(props) {
1855
+ const { t, provider, region, settingsScope } = props;
1856
+ if (t === void 0) throw new Error("Composer points readout requires its translation function");
1857
+ const owned = provider === void 0 ? void 0 : TRAE_COMPOSER_PROVIDERS[provider];
1858
+ const activeRegion = region ?? owned;
1859
+ const [usage, setUsage] = (0, react.useState)(void 0);
1860
+ const [accountCredits, setAccountCredits] = (0, react.useState)(void 0);
1861
+ const [busy, setBusy] = (0, react.useState)(false);
1862
+ const [switchingId, setSwitchingId] = (0, react.useState)(void 0);
1863
+ const [failed, setFailed] = (0, react.useState)(false);
1864
+ const [lastRefresh, setLastRefresh] = (0, react.useState)(void 0);
1865
+ const [open, setOpen] = (0, react.useState)(false);
1866
+ const inFlight = (0, react.useRef)(false);
1867
+ const mounted = (0, react.useRef)(true);
1868
+ const rootRef = (0, react.useRef)(null);
1869
+ const panelRef = (0, react.useRef)(null);
1870
+ (0, react.useEffect)(() => {
1871
+ mounted.current = true;
1872
+ return () => {
1873
+ mounted.current = false;
1874
+ };
1875
+ }, []);
1876
+ const position = useAnchoredPosition({
1877
+ open: open && activeRegion !== void 0,
1878
+ anchorRef: rootRef,
1879
+ panelRef,
1880
+ side: "top",
1881
+ gap: 8,
1882
+ margin: 12
1883
+ });
1884
+ useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef);
1885
+ const fetchUsage = (0, react.useCallback)(async (signal) => {
1886
+ if (inFlight.current || activeRegion === void 0) return;
1887
+ inFlight.current = true;
1888
+ if (mounted.current) setBusy(true);
1889
+ try {
1890
+ const response = await fetch(withTraeRegion(TRAE_USAGE_PATH, activeRegion), {
1891
+ headers: { accept: "application/json" },
1892
+ credentials: "same-origin",
1893
+ ...signal === void 0 ? {} : { signal }
1894
+ });
1895
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1896
+ const value = await response.json();
1897
+ if (!mounted.current || signal?.aborted === true) return;
1898
+ setUsage(value);
1899
+ setLastRefresh(Date.now());
1900
+ setFailed(false);
1901
+ } catch {
1902
+ if (mounted.current && signal?.aborted !== true) setFailed(true);
1903
+ } finally {
1904
+ inFlight.current = false;
1905
+ if (mounted.current) setBusy(false);
1906
+ }
1907
+ }, [activeRegion]);
1908
+ /**
1909
+ * Every account's general balance — one upstream read per account.
1910
+ *
1911
+ * Paid only while the panel is open (see the note at the top of this file).
1912
+ * A failure keeps whatever we already have rather than clearing it: a number
1913
+ * that went stale is better than a switch that vanished.
1914
+ */
1915
+ const fetchAccountCredits = (0, react.useCallback)(async (signal) => {
1916
+ if (activeRegion === void 0) return;
1917
+ try {
1918
+ const response = await fetch(withTraeRegion(TRAE_ACCOUNT_CREDITS_PATH, activeRegion), {
1919
+ headers: { accept: "application/json" },
1920
+ credentials: "same-origin",
1921
+ ...signal === void 0 ? {} : { signal }
1922
+ });
1923
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1924
+ const data = await response.json();
1925
+ if (!mounted.current || signal?.aborted === true) return;
1926
+ if (Array.isArray(data.accounts)) setAccountCredits(data.accounts);
1927
+ } catch {}
1928
+ }, [activeRegion]);
1929
+ (0, react.useEffect)(() => {
1930
+ if (owned === void 0) return void 0;
1931
+ let cancelled = false;
1932
+ let timer;
1933
+ const controller = new AbortController();
1934
+ fetchUsage(controller.signal).finally(() => {
1935
+ if (cancelled) return;
1936
+ timer = window.setInterval(() => {
1937
+ fetchUsage(controller.signal);
1938
+ }, COMPOSER_POINTS_REFRESH_INTERVAL_MS);
1939
+ });
1940
+ return () => {
1941
+ cancelled = true;
1942
+ if (timer !== void 0) window.clearInterval(timer);
1943
+ controller.abort();
1944
+ };
1945
+ }, [owned, fetchUsage]);
1946
+ (0, react.useEffect)(() => {
1947
+ if (!open || activeRegion === void 0) return void 0;
1948
+ const controller = new AbortController();
1949
+ fetchAccountCredits(controller.signal);
1950
+ return () => {
1951
+ controller.abort();
1952
+ };
1953
+ }, [
1954
+ open,
1955
+ activeRegion,
1956
+ fetchAccountCredits
1957
+ ]);
1958
+ (0, react.useEffect)(() => {
1959
+ if (!open) return void 0;
1960
+ const onKeyDown = (event) => {
1961
+ if (event.key === "Escape") setOpen(false);
1962
+ };
1963
+ document.addEventListener("keydown", onKeyDown);
1964
+ return () => {
1965
+ document.removeEventListener("keydown", onKeyDown);
1966
+ };
1967
+ }, [open]);
1968
+ /**
1969
+ * Switch to another account, then re-read both the usage document and the
1970
+ * table so the marker, the readout, and the panel all move together.
1971
+ *
1972
+ * The merge goes through the shared `configuredAccountsOf` helper (the card
1973
+ * uses the same one): writing only `[region]: id` would drop the other
1974
+ * region's selection. The write is read-back checked — `set()` resolving is
1975
+ * not proof it landed — so a refused write reports failure instead of
1976
+ * showing a marker on an account that was never selected.
1977
+ */
1978
+ const switchTo = async (accountId) => {
1979
+ if (settingsScope === void 0 || settingsScope.getSnapshot().writable !== true) return;
1980
+ if (activeRegion === void 0 || switchingId !== void 0) return;
1981
+ if (accountId === (accountCredits?.find((account) => account.selected)?.id ?? (usage?.status === "signed-in" ? usage.accountId : void 0))) return;
1982
+ setSwitchingId(accountId);
1983
+ setFailed(false);
1984
+ try {
1985
+ const configured = configuredAccountsOf(settingsScope.getSnapshot().value);
1986
+ const accepted = await settingsScope.set("accounts", {
1987
+ ...configured,
1988
+ [activeRegion]: accountId
1989
+ });
1990
+ const readBack = configuredAccountsOf(settingsScope.getSnapshot().value);
1991
+ if (accepted === false || readBack[activeRegion] !== accountId) throw new Error("the Host did not persist the switch");
1992
+ await fetchUsage();
1993
+ await fetchAccountCredits();
1994
+ } catch {
1995
+ if (mounted.current) setFailed(true);
1996
+ } finally {
1997
+ if (mounted.current) setSwitchingId(void 0);
1998
+ }
1999
+ };
2000
+ if (owned === void 0) return null;
2001
+ const signedIn = usage?.status === "signed-in" ? usage : void 0;
2002
+ const general = signedIn === void 0 ? void 0 : generalCreditsOf(signedIn);
2003
+ const signedOut = usage !== void 0 && usage.status === "signed-out";
2004
+ const valueText = general === void 0 ? "—" : formatCredits(general);
2005
+ const label = t("composer.points");
2006
+ /**
2007
+ * The table's rows: every account in this region, with its general balance.
2008
+ *
2009
+ * The endpoint's answer is authoritative (it carries the figures). Before it
2010
+ * lands — or if it fails — fall back to the usage document's account list so
2011
+ * the switches are reachable immediately, showing `—` for the figure rather
2012
+ * than a spinner the user has to wait on.
2013
+ */
2014
+ const selectedId = accountCredits?.find((account) => account.selected)?.id ?? (signedIn === void 0 ? void 0 : signedIn.accountId);
2015
+ const rows = (accountCredits ?? (signedIn === void 0 ? [] : signedIn.accounts.filter((account) => account.region === activeRegion).map((account) => ({
2016
+ id: account.id,
2017
+ accountName: account.accountName,
2018
+ selected: account.selected
2019
+ })))).map((row) => {
2020
+ if (row.id !== selectedId || row.generalAvailable !== void 0 || general === void 0) return row;
2021
+ return {
2022
+ ...row,
2023
+ generalAvailable: general
2024
+ };
2025
+ });
2026
+ const canSwitch = settingsScope !== void 0 && settingsScope.getSnapshot().writable === true;
2027
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2028
+ ref: rootRef,
2029
+ className: "dsm-trae-composer-points",
2030
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2031
+ type: "button",
2032
+ className: "dsm-trae-composer-points-trigger",
2033
+ "aria-haspopup": "dialog",
2034
+ "aria-expanded": open,
2035
+ onClick: () => {
2036
+ setOpen(!open);
2037
+ },
2038
+ children: [
2039
+ label,
2040
+ " · ",
2041
+ valueText
2042
+ ]
2043
+ }), open && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2044
+ ref: panelRef,
2045
+ className: "dsm-trae-composer-panel",
2046
+ style: position ?? {
2047
+ visibility: "hidden",
2048
+ left: 0,
2049
+ top: 0
2050
+ },
2051
+ role: "dialog",
2052
+ "aria-label": t("composer.panelTitle"),
2053
+ children: [
2054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2055
+ className: "dsm-trae-composer-panel-head",
2056
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2057
+ className: "dsm-trae-composer-panel-title",
2058
+ children: t("composer.panelTitle")
2059
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2060
+ type: "button",
2061
+ className: "dsm-trae-composer-panel-refresh",
2062
+ disabled: busy || switchingId !== void 0,
2063
+ onClick: () => {
2064
+ fetchUsage();
2065
+ },
2066
+ children: busy ? t("composer.refreshing") : t("composer.refresh")
2067
+ })]
2068
+ }),
2069
+ signedOut ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2070
+ className: "dsm-trae-composer-panel-empty",
2071
+ children: t("composer.signedOut")
2072
+ }) : rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2073
+ className: "dsm-trae-composer-panel-empty",
2074
+ children: t("composer.noAccounts")
2075
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
2076
+ className: "dsm-trae-composer-panel-table",
2077
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
2078
+ scope: "col",
2079
+ children: t("composer.account")
2080
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
2081
+ scope: "col",
2082
+ className: "dsm-trae-composer-panel-num",
2083
+ children: t("composer.generalCredits")
2084
+ })] }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: rows.map((row) => {
2085
+ const current = row.id === selectedId;
2086
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
2087
+ className: current ? "dsm-trae-composer-panel-row-current" : void 0,
2088
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("th", {
2089
+ scope: "row",
2090
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2091
+ type: "button",
2092
+ className: "dsm-trae-composer-panel-switch",
2093
+ disabled: !canSwitch || switchingId !== void 0 || current,
2094
+ "aria-label": t("composer.switchTo", { account: row.accountName }),
2095
+ onClick: () => {
2096
+ switchTo(row.id);
2097
+ },
2098
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2099
+ className: "dsm-trae-composer-panel-dot",
2100
+ "aria-hidden": "true"
2101
+ }), row.accountName]
2102
+ })
2103
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
2104
+ className: "dsm-trae-composer-panel-num",
2105
+ children: row.generalAvailable === void 0 ? "—" : formatCredits(row.generalAvailable)
2106
+ })]
2107
+ }, row.id);
2108
+ }) })]
2109
+ }),
2110
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2111
+ className: "dsm-trae-composer-panel-foot",
2112
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2113
+ t("composer.lastRefresh"),
2114
+ " ",
2115
+ lastRefresh === void 0 ? "—" : formatClock(lastRefresh)
2116
+ ] })
2117
+ }),
2118
+ failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2119
+ className: "dsm-trae-composer-panel-error",
2120
+ role: "status",
2121
+ children: t("composer.refreshFailed")
2122
+ }) : null
2123
+ ]
2124
+ }), document.body)]
2125
+ });
2126
+ }
2127
+ //#endregion
2128
+ //#region src/client/ComposerPointsGate.tsx
2129
+ /**
2130
+ * Decides whether the composer credit readout belongs in this session, by
2131
+ * reading the Host's own `modelSelection` projection and the plugin's
2132
+ * `showPointsInMainUi` switch.
2133
+ *
2134
+ * Kept separate from {@link ComposerPoints} (and from the browser-plugin entry)
2135
+ * so the decision can be unit-tested without a Host: the entry imports
2136
+ * browser-only DSH packages the test environment cannot load, while this module
2137
+ * depends only on React and a narrow projection shape.
2138
+ *
2139
+ * WHY the projection rather than this plugin's own state: the projection is
2140
+ * what the model picker writes and what the next request will use, so the
2141
+ * readout cannot disagree with the model actually in effect. `next` outranks
2142
+ * `lastUsed` because a selection that has been made but not yet sent is still
2143
+ * what the composer shows the user.
2144
+ *
2145
+ * The switch is read the same way the retired sidebar gate read it: absent or
2146
+ * false means OFF, and only an explicit `true` shows the readout. The composer
2147
+ * row is shared with the shell's own permission/agent/model controls, so the
2148
+ * readout must be asked for, not imposed — 2.13.0 rendered it unconditionally
2149
+ * and 2.13.3 restored the opt-in after the user went looking for the switch.
2150
+ */
2151
+ /**
2152
+ * The provider route the session is currently pointed at.
2153
+ *
2154
+ * `next` wins over `lastUsed`: a switch that has landed but not yet been sent
2155
+ * is still the selection the composer displays.
2156
+ */
2157
+ function selectedProviderOf(projection) {
2158
+ const provider = (projection?.next ?? projection?.lastUsed)?.provider;
2159
+ return typeof provider === "string" && provider !== "" ? provider : void 0;
2160
+ }
2161
+ /**
2162
+ * Read the composer-readout switch out of a committed settings snapshot.
2163
+ *
2164
+ * Absent means OFF, matching the host default (`false`): a scope that has not
2165
+ * populated yet, or a host too old to serve the field, must not put a row in
2166
+ * the composer toolbar that the user never asked for.
2167
+ */
2168
+ function composerPointsEnabledOf(scope) {
2169
+ if (scope === void 0) return false;
2170
+ return unwrapVolatileDeep(scope.getSnapshot().value)?.showPointsInMainUi === true;
2171
+ }
2172
+ function ComposerPointsGate(props) {
2173
+ const { t, settingsScope, useProjection } = props;
2174
+ const [enabled, setEnabled] = (0, react.useState)(() => composerPointsEnabledOf(settingsScope));
2175
+ (0, react.useEffect)(() => settingsScope?.subscribe(() => {
2176
+ setEnabled(composerPointsEnabledOf(settingsScope));
2177
+ }), [settingsScope]);
2178
+ const projection = useProjection === void 0 ? void 0 : useProjection("modelSelection");
2179
+ if (!enabled) return null;
2180
+ const provider = selectedProviderOf(projection);
2181
+ const region = provider === void 0 ? void 0 : TRAE_COMPOSER_PROVIDERS[provider];
2182
+ if (region === void 0) return null;
2183
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ComposerPoints, {
2184
+ t,
2185
+ provider,
2186
+ region,
2187
+ ...settingsScope === void 0 ? {} : { settingsScope }
2188
+ });
2189
+ }
2190
+ //#endregion
1467
2191
  //#region src/client/locales.ts
1468
2192
  /**
1469
2193
  * Plugin-card copy registered under the settings.trae locale namespace.
@@ -1537,6 +2261,19 @@ window.__ModuleLoader__.load({
1537
2261
  "row.modelsTestEnabled": "Test enabled models",
1538
2262
  "row.modelsTesting": "Testing {done}/{total}…",
1539
2263
  "row.modelsTestHint": "Sends one minimal message per enabled model to see which ones actually answer. Costs a small amount of quota.",
2264
+ "row.showPointsInMainUi": "Show credits in the composer row",
2265
+ "row.showPointsInMainUiHint": "Show a clickable Trae CN · <balance> readout in the composer tool row, only while the selected model belongs to this plugin. Click it for the account and balance panel.",
2266
+ "composer.switchTo": "Switch to {account}",
2267
+ "composer.noAccounts": "No account in this region",
2268
+ "composer.panelTitle": "Trae credits",
2269
+ "composer.account": "Account",
2270
+ "composer.generalCredits": "General credits",
2271
+ "composer.lastRefresh": "Last refreshed",
2272
+ "composer.signedOut": "Not signed in",
2273
+ "composer.points": "Trae CN",
2274
+ "composer.refresh": "Refresh credits",
2275
+ "composer.refreshing": "Refreshing credits…",
2276
+ "composer.refreshFailed": "Could not refresh credits",
1540
2277
  "row.accountsRefreshed": "Accounts refreshed: {count} found{selected}",
1541
2278
  "row.accountsKept": "still bound to",
1542
2279
  "row.accountsRefreshedNone": "Accounts refreshed: no usable Trae account found",
@@ -1640,6 +2377,19 @@ window.__ModuleLoader__.load({
1640
2377
  "row.modelsTestEnabled": "一键测试启用模型",
1641
2378
  "row.modelsTesting": "正在测试 {done}/{total}…",
1642
2379
  "row.modelsTestHint": "给每个已启用模型发一条最小消息,看哪些真的能应答。会消耗少量额度。",
2380
+ "row.showPointsInMainUi": "在输入框工具栏显示积分读数",
2381
+ "row.showPointsInMainUiHint": "在输入框工具栏显示可点击的「Trae CN · 余额」读数,仅当选中的模型属于本插件时出现;点击可查看账号与积分明细。",
2382
+ "composer.switchTo": "切换到 {account}",
2383
+ "composer.noAccounts": "该区域暂无账号",
2384
+ "composer.panelTitle": "Trae 积分",
2385
+ "composer.account": "账号",
2386
+ "composer.generalCredits": "通用积分",
2387
+ "composer.lastRefresh": "最近刷新",
2388
+ "composer.signedOut": "未登录",
2389
+ "composer.points": "Trae CN",
2390
+ "composer.refresh": "刷新积分",
2391
+ "composer.refreshing": "正在刷新积分…",
2392
+ "composer.refreshFailed": "积分刷新失败",
1643
2393
  "row.accountsRefreshed": "已刷新账号:找到 {count} 个账号{selected}",
1644
2394
  "row.accountsKept": "仍绑定",
1645
2395
  "row.accountsRefreshedNone": "已刷新账号:未找到可用的 Trae 账号",
@@ -1800,6 +2550,38 @@ window.__ModuleLoader__.load({
1800
2550
  };
1801
2551
  registerCard("plugins.bundle.config", "dsh-connect-trae");
1802
2552
  registerCard("plugins.row.config", "dsh-connect-trae#dsh-connect-trae");
2553
+ /**
2554
+ * Credit readout in the composer tool row (`conversation.input.left`, a
2555
+ * session-scope list slot).
2556
+ *
2557
+ * Provider-scoped on purpose: it renders only while the session's selected
2558
+ * model belongs to this plugin. That is what keeps two connector plugins
2559
+ * from competing for one shared row — the sidebar foot, where this line used
2560
+ * to live, is a single root row, and the observed result beside
2561
+ * dsh-connect-workbuddy's two rows was this plugin's line squeezed down to a
2562
+ * clipped "Tr".
2563
+ *
2564
+ * Registration is unconditional; the gate reads the Host's modelSelection
2565
+ * projection and returns null for every other provider, so nothing is drawn
2566
+ * and no fetch loop starts while another provider is selected.
2567
+ */
2568
+ const registerComposerPoints = () => {
2569
+ try {
2570
+ ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
2571
+ name: "conversation.input.left",
2572
+ id: "dsh-connect-trae-composer-points",
2573
+ order: 4,
2574
+ locale: namespace,
2575
+ inject: () => ({
2576
+ t,
2577
+ settingsScope
2578
+ })
2579
+ }, ComposerPointsGate));
2580
+ } catch (error) {
2581
+ console.error("[dsh-connect-trae] composer points slot failed to register (host provider unaffected):", error);
2582
+ }
2583
+ };
2584
+ registerComposerPoints();
1803
2585
  } catch (error) {
1804
2586
  console.error("[dsh-connect-trae] client card failed to load (host provider unaffected):", error);
1805
2587
  }