dsh-provider-usage 0.3.9 → 0.3.11

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
@@ -13,6 +13,7 @@ window.__ModuleLoader__.load({
13
13
  "action.label": "用量",
14
14
  "action.aria": "Provider 用量",
15
15
  "panel.title": "Provider 用量",
16
+ "panel.resize": "拖拽调整面板高度",
16
17
  "panel.refresh": "立即刷新",
17
18
  "panel.refreshing": "刷新中…",
18
19
  "lang.switch": "Switch to English",
@@ -56,6 +57,7 @@ window.__ModuleLoader__.load({
56
57
  "action.label": "Usage",
57
58
  "action.aria": "Provider usage",
58
59
  "panel.title": "Provider Usage",
60
+ "panel.resize": "Drag to resize panel",
59
61
  "panel.refresh": "Refresh now",
60
62
  "panel.refreshing": "Refreshing…",
61
63
  "lang.switch": "切换到中文",
@@ -117,11 +119,52 @@ window.__ModuleLoader__.load({
117
119
  const POS_KEY = "dsh.provider-usage.floatPos";
118
120
  const THRESHOLD_RED_KEY = "dsh.provider-usage.balanceRedThreshold";
119
121
  const THRESHOLD_YELLOW_KEY = "dsh.provider-usage.balanceYellowThreshold";
122
+ const PANEL_HEIGHT_KEY = "dsh.provider-usage.panelHeight";
120
123
  const DEFAULT_INTERVAL = 60;
121
124
  /** Balance thresholds fall back to these hardcoded defaults when neither the
122
125
  host config nor a stored override provides them. */
123
126
  const DEFAULT_BALANCE_RED = 10;
124
127
  const DEFAULT_BALANCE_YELLOW = 30;
128
+ /** Smallest panel height the top-edge drag allows (px). */
129
+ const PANEL_MIN_H = 120;
130
+ /** Largest panel height the top-edge drag allows: the viewport minus a
131
+ breathing margin, so the panel can never outgrow the screen. */
132
+ function panelMaxHeight() {
133
+ return Math.max(PANEL_MIN_H, window.innerHeight - 24);
134
+ }
135
+ /** Bottom padding of the open panel (px), read live so the layout math stays
136
+ correct if the stylesheet ever changes it. */
137
+ function panelPaddingBottom(panel) {
138
+ const pad = Number.parseFloat(getComputedStyle(panel).paddingBottom);
139
+ return Number.isFinite(pad) ? pad : 0;
140
+ }
141
+ /**
142
+ * Natural content height of the open panel — children plus padding — even
143
+ * while the panel itself is height-constrained or scrolled (children rects
144
+ * are compensated by scrollTop). This is the grow limit: at this height the
145
+ * panel needs no scrolling and leaves no blank strip at the bottom.
146
+ */
147
+ function panelContentHeight(panel) {
148
+ const panelTop = panel.getBoundingClientRect().top;
149
+ let bottom = 0;
150
+ for (const child of Array.from(panel.children)) {
151
+ const rect = child.getBoundingClientRect();
152
+ bottom = Math.max(bottom, rect.bottom - panelTop + panel.scrollTop);
153
+ }
154
+ return Math.ceil(bottom) + panelPaddingBottom(panel);
155
+ }
156
+ /**
157
+ * Shrink limit: the height that keeps the panel head plus the FIRST provider
158
+ * card fully visible (bottom padding included). Falls back to PANEL_MIN_H
159
+ * when no provider card is present (e.g. empty list).
160
+ */
161
+ function firstCardMinHeight(panel) {
162
+ const card = panel.querySelector(".dsh-usage-card");
163
+ if (card === null) return PANEL_MIN_H;
164
+ const panelTop = panel.getBoundingClientRect().top;
165
+ const cardRect = card.getBoundingClientRect();
166
+ return Math.ceil(cardRect.bottom - panelTop + panel.scrollTop) + panelPaddingBottom(panel);
167
+ }
125
168
  /** Floating ball diameter in px; drag math derives from it. */
126
169
  const BALL_SIZE = 32;
127
170
  /** Panel-level override stored in localStorage; null means follow the harness language. */
@@ -183,6 +226,21 @@ window.__ModuleLoader__.load({
183
226
  localStorage.setItem(key, value);
184
227
  } catch {}
185
228
  }
229
+ function readStoredPanelHeight() {
230
+ try {
231
+ const raw = localStorage.getItem(PANEL_HEIGHT_KEY);
232
+ if (raw === null) return null;
233
+ const value = Number(raw);
234
+ return Number.isFinite(value) && value >= PANEL_MIN_H ? value : null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+ function storePanelHeight(value) {
240
+ try {
241
+ localStorage.setItem(PANEL_HEIGHT_KEY, String(value));
242
+ } catch {}
243
+ }
186
244
  /** Equal margin from the chat area's left edge and the window's bottom edge. */
187
245
  const DOCK_MARGIN = 24;
188
246
  /**
@@ -271,29 +329,35 @@ window.__ModuleLoader__.load({
271
329
  if (value < yellow) return "warn";
272
330
  return "ok";
273
331
  }
332
+ const TONE_SEVERITY = {
333
+ ok: 0,
334
+ warn: 1,
335
+ danger: 2
336
+ };
337
+ /** More severe of two tones; used WITHIN one category. */
338
+ function worseTone(a, b) {
339
+ if (a === null) return b;
340
+ if (b === null) return a;
341
+ return TONE_SEVERITY[a] >= TONE_SEVERITY[b] ? a : b;
342
+ }
274
343
  /**
275
- * Tone for one usage row. Windows with a percent (5h/weekly limits) keep the
276
- * percent-based tone; rows labeled 'credits' carry a remaining balance and
277
- * are judged by the balance thresholds instead.
344
+ * Aggregate tone for one provider. The subscription/usage windows ("plan")
345
+ * and the prepaid balance/credits are two INDEPENDENT categories joined with
346
+ * OR semantics: the provider stays green while either category has enough
347
+ * left (the plan is normally consumed before credits, so the surviving
348
+ * resource sets the tone), and when both are running low the lighter warning
349
+ * wins. Within one category the worst row still governs — an exhausted 5h
350
+ * window is not "enough plan" just because the weekly window is healthy.
278
351
  */
279
- function usageRowTone(row, red, yellow) {
280
- if (row.label === "credits") return balanceTone(row.remaining, red, yellow);
281
- return barTone(row.percent);
282
- }
283
- /** Worst balance/usage tone across one provider's balances and usage rows. */
284
352
  function providerTone(provider, red, yellow) {
285
- let tone = "ok";
286
- if (provider.kind === "balance") for (const row of provider.balances ?? []) {
287
- const rowTone = balanceTone(toAmount(row.total), red, yellow);
288
- if (rowTone === "danger") return "danger";
289
- if (rowTone === "warn") tone = "warn";
290
- }
291
- for (const row of provider.usages ?? []) {
292
- const rowTone = usageRowTone(row, red, yellow);
293
- if (rowTone === "danger") return "danger";
294
- if (rowTone === "warn") tone = "warn";
295
- }
296
- return tone;
353
+ let planTone = null;
354
+ let creditTone = null;
355
+ if (provider.kind === "balance") for (const row of provider.balances ?? []) creditTone = worseTone(creditTone, balanceTone(toAmount(row.total), red, yellow));
356
+ for (const row of provider.usages ?? []) if (row.label === "credits") creditTone = worseTone(creditTone, balanceTone(row.remaining, red, yellow));
357
+ else planTone = worseTone(planTone, barTone(row.percent));
358
+ if (planTone === null) return creditTone ?? "ok";
359
+ if (creditTone === null) return planTone;
360
+ return TONE_SEVERITY[planTone] <= TONE_SEVERITY[creditTone] ? planTone : creditTone;
297
361
  }
298
362
  /** Worst health across the fetch + the provider IN USE: drives the trigger's
299
363
  status dot. Only the active provider (the composer's current selection)
@@ -317,6 +381,30 @@ window.__ModuleLoader__.load({
317
381
  if (value === null) return "—";
318
382
  return Number.isInteger(value) ? String(value) : value.toFixed(2);
319
383
  }
384
+ /**
385
+ * Refine the host's `active` flags with the provider of the FOCUSED session
386
+ * (the composer's own state), so a session switch re-highlights instantly
387
+ * even though the host only tracks the last explicit selection. Returns the
388
+ * same object when nothing changes; `undefined` provider leaves the host's
389
+ * flags untouched.
390
+ */
391
+ function applyActiveProvider(data, provider) {
392
+ if (provider === void 0 || !Array.isArray(data.providers)) return data;
393
+ let changed = false;
394
+ const providers = data.providers.map((entry) => {
395
+ const active = entry.id === provider;
396
+ if (active === entry.active) return entry;
397
+ changed = true;
398
+ return {
399
+ ...entry,
400
+ active
401
+ };
402
+ });
403
+ return changed ? {
404
+ ...data,
405
+ providers
406
+ } : data;
407
+ }
320
408
  function UsageRows({ provider, now, red, yellow, t }) {
321
409
  const rows = provider.usages ?? [];
322
410
  if (rows.length === 0) return null;
@@ -497,7 +585,7 @@ window.__ModuleLoader__.load({
497
585
  ]
498
586
  });
499
587
  }
500
- function UsageAction({ t, fetchUsage }) {
588
+ function UsageAction({ t, fetchUsage, getActiveProvider, subscribeActiveChange }) {
501
589
  const [open, setOpen] = (0, react.useState)(false);
502
590
  const [data, setData] = (0, react.useState)(null);
503
591
  const [error, setError] = (0, react.useState)(null);
@@ -519,6 +607,9 @@ window.__ModuleLoader__.load({
519
607
  });
520
608
  /** Transient ball position while dragging (cursor-centered); null when docked. */
521
609
  const [dragPos, setDragPos] = (0, react.useState)(null);
610
+ /** User-resized panel height in px; null = auto-size to content (max-height
611
+ + scroll). Persisted, like every other panel preference. */
612
+ const [panelHeight, setPanelHeight] = (0, react.useState)(() => readStoredPanelHeight());
522
613
  const harnessLang = t("lang.switch") === zh["lang.switch"] ? "zh" : t("lang.switch") === en["lang.switch"] ? "en" : null;
523
614
  const lang = langOverride ?? harnessLang ?? "en";
524
615
  const tt = (0, react.useMemo)(() => makeT(lang === "zh" ? zh : en), [lang]);
@@ -543,13 +634,18 @@ window.__ModuleLoader__.load({
543
634
  const dragRef = (0, react.useRef)(null);
544
635
  /** Set when a drag ends so the trailing click does not toggle the panel. */
545
636
  const suppressClickRef = (0, react.useRef)(false);
637
+ /** Active panel-height drag; cleared on pointerup/pointercancel. */
638
+ const resizeRef = (0, react.useRef)(null);
639
+ /** Resize bounds snapshotted at drag start from the live layout: min = first
640
+ provider card fully visible, max = content height (no scroll, no blank). */
641
+ const resizeLimitsRef = (0, react.useRef)(null);
546
642
  const refresh = (0, react.useCallback)(async () => {
547
643
  if (inFlight.current) return;
548
644
  inFlight.current = true;
549
645
  setLoading(true);
550
646
  try {
551
647
  const result = await fetchUsage();
552
- setData(result);
648
+ setData(applyActiveProvider(result, getActiveProvider?.()));
553
649
  setError(null);
554
650
  } catch (reason) {
555
651
  setError(reason instanceof Error ? reason.message : String(reason));
@@ -557,7 +653,7 @@ window.__ModuleLoader__.load({
557
653
  inFlight.current = false;
558
654
  setLoading(false);
559
655
  }
560
- }, [fetchUsage]);
656
+ }, [fetchUsage, getActiveProvider]);
561
657
  (0, react.useEffect)(() => {
562
658
  refresh();
563
659
  }, [refresh]);
@@ -575,6 +671,11 @@ window.__ModuleLoader__.load({
575
671
  if (readStoredThreshold(THRESHOLD_RED_KEY) === null && typeof data.balanceRedThreshold === "number") setRedThreshold(String(data.balanceRedThreshold));
576
672
  if (readStoredThreshold(THRESHOLD_YELLOW_KEY) === null && typeof data.balanceYellowThreshold === "number") setYellowThreshold(String(data.balanceYellowThreshold));
577
673
  }, [data]);
674
+ (0, react.useEffect)(() => {
675
+ return subscribeActiveChange?.(() => {
676
+ setData((prev) => prev === null ? prev : applyActiveProvider(prev, getActiveProvider?.()));
677
+ });
678
+ }, []);
578
679
  (0, react.useEffect)(() => {
579
680
  if (!open) return;
580
681
  setNow(Date.now());
@@ -615,6 +716,16 @@ window.__ModuleLoader__.load({
615
716
  pinnedPos,
616
717
  viewport
617
718
  ]);
719
+ (0, react.useLayoutEffect)(() => {
720
+ const panel = panelRef.current;
721
+ if (panel === null || panelHeight === null) return;
722
+ const clamped = Math.min(panelHeight, panelContentHeight(panel), panelMaxHeight());
723
+ if (clamped !== panelHeight) setPanelHeight(clamped);
724
+ }, [
725
+ panelHeight,
726
+ data,
727
+ open
728
+ ]);
618
729
  (0, react.useEffect)(() => {
619
730
  const onResize = () => setViewport({
620
731
  w: window.innerWidth,
@@ -675,6 +786,45 @@ window.__ModuleLoader__.load({
675
786
  dragRef.current = null;
676
787
  setDragPos(null);
677
788
  };
789
+ const onResizePointerDown = (event) => {
790
+ event.currentTarget.setPointerCapture(event.pointerId);
791
+ const panel = panelRef.current;
792
+ const rect = panel?.getBoundingClientRect();
793
+ const startHeight = panelHeight ?? Math.round(rect?.height ?? 0);
794
+ if (panel !== null) {
795
+ const viewportMax = panelMaxHeight();
796
+ const max = Math.min(panelContentHeight(panel), viewportMax);
797
+ const min = Math.min(Math.max(firstCardMinHeight(panel), PANEL_MIN_H), max);
798
+ resizeLimitsRef.current = {
799
+ min,
800
+ max
801
+ };
802
+ } else resizeLimitsRef.current = {
803
+ min: PANEL_MIN_H,
804
+ max: panelMaxHeight()
805
+ };
806
+ resizeRef.current = {
807
+ pointerId: event.pointerId,
808
+ startY: event.clientY,
809
+ startHeight,
810
+ current: startHeight
811
+ };
812
+ };
813
+ const onResizePointerMove = (event) => {
814
+ const resize = resizeRef.current;
815
+ if (resize === null || resize.pointerId !== event.pointerId) return;
816
+ const limits = resizeLimitsRef.current;
817
+ const next = limits === null ? resize.startHeight + (resize.startY - event.clientY) : Math.min(Math.max(resize.startHeight + (resize.startY - event.clientY), limits.min), limits.max);
818
+ resize.current = next;
819
+ setPanelHeight(next);
820
+ };
821
+ const endResize = (event) => {
822
+ const resize = resizeRef.current;
823
+ if (resize === null || resize.pointerId !== event.pointerId) return;
824
+ resizeRef.current = null;
825
+ resizeLimitsRef.current = null;
826
+ storePanelHeight(resize.current);
827
+ };
678
828
  const onBallClick = () => {
679
829
  if (suppressClickRef.current) {
680
830
  suppressClickRef.current = false;
@@ -717,10 +867,23 @@ window.__ModuleLoader__.load({
717
867
  style: {
718
868
  left: panelPos.left,
719
869
  top: panelPos.top,
720
- bottom: panelPos.bottom
870
+ bottom: panelPos.bottom,
871
+ ...panelHeight !== null ? {
872
+ height: Math.min(panelHeight, panelMaxHeight()),
873
+ maxHeight: "none"
874
+ } : {}
721
875
  },
722
876
  onKeyDown,
723
877
  children: [
878
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
879
+ className: "dsh-usage-resize",
880
+ title: tt("panel.resize"),
881
+ "aria-label": tt("panel.resize"),
882
+ onPointerDown: onResizePointerDown,
883
+ onPointerMove: onResizePointerMove,
884
+ onPointerUp: endResize,
885
+ onPointerCancel: endResize
886
+ }),
724
887
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
725
888
  className: "dsh-usage-head",
726
889
  children: [
@@ -921,6 +1084,27 @@ window.__ModuleLoader__.load({
921
1084
  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
922
1085
  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
923
1086
  }
1087
+ /* Top-edge resize handle: sticks to the panel top even while the body
1088
+ scrolls, so it is always grabbable; drag to make the panel taller/shorter
1089
+ (height persisted in localStorage). */
1090
+ .dsh-usage-resize {
1091
+ position: sticky; top: 0; z-index: 2; flex: none;
1092
+ height: 6px; margin: -4px 0 0;
1093
+ cursor: ns-resize; touch-action: none;
1094
+ border-radius: 3px;
1095
+ }
1096
+ .dsh-usage-resize::after {
1097
+ content: ''; position: absolute; left: 50%; top: 50%;
1098
+ transform: translate(-50%, -50%);
1099
+ width: 32px; height: 3px; border-radius: 2px;
1100
+ background: var(--dsw-alias-border-l2);
1101
+ }
1102
+ .dsh-usage-resize:hover, .dsh-usage-resize:active {
1103
+ background: color-mix(in srgb, var(--dsw-alias-accent, #4f8cff) 25%, transparent);
1104
+ }
1105
+ .dsh-usage-resize:hover::after, .dsh-usage-resize:active::after {
1106
+ background: var(--dsw-alias-accent, #4f8cff);
1107
+ }
924
1108
  .dsh-usage-head {
925
1109
  display: flex; align-items: center; gap: 8px; padding: 2px 4px 6px;
926
1110
  border-bottom: 1px solid var(--dsw-alias-border-l2);
@@ -1055,12 +1239,27 @@ window.__ModuleLoader__.load({
1055
1239
  * sidebar footer's additive action slot. Data comes from the host `usage`
1056
1240
  * Typert Remote (SRC mode — no generated descriptors) through the raw RPC
1057
1241
  * caller on the connection service.
1242
+ *
1243
+ * The "provider in use" flag is refined CLIENT-side: the host marks the route
1244
+ * from the GLOBAL default model selection, which only moves on an explicit
1245
+ * composer pick — switching between sessions with different per-session
1246
+ * selections would leave it stale. The focused session's own effective
1247
+ * selection (pending → last used → host default, mirrored by the composer's
1248
+ * model seat) is read live from the client session services and overrides the
1249
+ * host flag, so the panel follows session switches immediately.
1250
+ */
1251
+ /**
1252
+ * Required client services: slot registry, locale seats, the connection RPC
1253
+ * caller, and the live session/model-selection state used to refine the
1254
+ * "in use" flag (`sessions` + `modelDirectories`, both mounted by the web
1255
+ * app — the latter is the composer model seat's own service).
1058
1256
  */
1059
- /** Required client services: slot registry, locale seats, and the connection RPC caller. */
1060
1257
  const inject = [
1061
1258
  "slots",
1062
1259
  "locale",
1063
- "connection"
1260
+ "connection",
1261
+ "sessions",
1262
+ "modelDirectories"
1064
1263
  ];
1065
1264
  function apply(ctx) {
1066
1265
  ensureStyles();
@@ -1073,12 +1272,61 @@ window.__ModuleLoader__.load({
1073
1272
  if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`);
1074
1273
  return result.value;
1075
1274
  };
1275
+ /**
1276
+ * The provider the FOCUSED session's composer shows (and its agent would
1277
+ * use): the session's durable model selection, else the deployment default
1278
+ * — exactly what the host resolves per session. Undefined while the client
1279
+ * session state is not ready; the host's own flag then stands.
1280
+ */
1281
+ const getActiveProvider = () => {
1282
+ const current = ctx.sessions?.list?.getSnapshot()?.current;
1283
+ if (typeof current !== "string" || current === "") return void 0;
1284
+ try {
1285
+ const provider = (ctx.modelDirectories?.directoryFor(current))?.store?.getSnapshot()?.current?.provider;
1286
+ return typeof provider === "string" && provider !== "" ? provider : void 0;
1287
+ } catch {
1288
+ return;
1289
+ }
1290
+ };
1291
+ /**
1292
+ * Re-derive the panel's "in use" flag the moment the focused session or its
1293
+ * model selection changes — no need to wait for the next poll tick. The
1294
+ * directory subscription follows the CURRENT focused session and is
1295
+ * re-pointed on every session switch, so a late projection load still lands.
1296
+ */
1297
+ const subscribeActiveChange = (onChange) => {
1298
+ const sessions = ctx.sessions;
1299
+ if (typeof sessions?.list?.subscribe !== "function") return () => {};
1300
+ let directoryUnsub = null;
1301
+ const followDirectory = () => {
1302
+ directoryUnsub?.();
1303
+ directoryUnsub = null;
1304
+ const current = sessions.list.getSnapshot()?.current;
1305
+ if (typeof current === "string" && current !== "") try {
1306
+ const store = ctx.modelDirectories?.directoryFor(current)?.store;
1307
+ if (typeof store?.subscribe === "function") directoryUnsub = store.subscribe(onChange);
1308
+ } catch {}
1309
+ };
1310
+ const offList = sessions.list.subscribe(() => {
1311
+ followDirectory();
1312
+ onChange();
1313
+ });
1314
+ followDirectory();
1315
+ return () => {
1316
+ offList?.();
1317
+ directoryUnsub?.();
1318
+ };
1319
+ };
1076
1320
  ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
1077
1321
  name: "sidebar.footer.action",
1078
1322
  id: "provider-usage",
1079
1323
  order: 10,
1080
1324
  locale: "provider-usage",
1081
- inject: () => ({ fetchUsage })
1325
+ inject: () => ({
1326
+ fetchUsage,
1327
+ getActiveProvider,
1328
+ subscribeActiveChange
1329
+ })
1082
1330
  }, UsageAction));
1083
1331
  }
1084
1332
  //#endregion