@iasiv5/dsh-quota-watch 0.0.3 → 0.0.10

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/README.md CHANGED
@@ -6,12 +6,13 @@
6
6
 
7
7
  ## 功能
8
8
 
9
- - 在侧栏 Settings 行上方显示一张常显摘要卡(无折叠按钮):GLM 与 GitHub Copilot 各占一行,统一显示**已用百分比**(Copilot 为 `100 − percent_remaining` 的单值反转),已用超过 80% 进度条转橙、超过 95% 转红。
10
- - GLM 摘要行固定显示 5 小时窗口已用百分比,行尾小字为当日 token 用量;点击行展开详情:今日 tokens / 调用次数双大数字、全部窗口(5 小时 / 每周 / 每月 / MCP 月度 `TIME_LIMIT`)的百分比与重置时间、分模型用量与更新时间。同一时间至多展开一个详情。
11
- - GitHub Copilot 摘要行仅在 GitHub 返回有限、有效的 `remaining` 且带 `percent_remaining` 时显示;点击展开可用额度、已用百分比、`credits_used` 与重置时间。`credits_used` 绝不用于推算余额,缺失有效百分比时该行不渲染。
9
+ - 在侧栏底部区块的最顶部(框架渲染的插件入口与设置行之上)显示一张常显摘要卡(无标题、无刷新按钮):GLM 与 GitHub Copilot 各占一行,统一显示**已用百分比**(Copilot 为 `100 − percent_remaining` 的单值反转),已用超过 80% 进度条转橙、超过 95% 转红;点击行首的 GLM / Copilot 名称立即触发一次宿主探测(等同原刷新按钮),点击行其余区域展开详情。
10
+ - GLM 摘要行固定显示 5 小时窗口已用百分比,行尾等宽小字为当日 token 用量;点击行在侧栏右侧弹出详情面板(半透明毛玻璃底,深浅主题均可读,不改变卡片高度):今日 Tokens / 调用次数双大数字、窗口区为五列对齐网格(名称 | `·` | `已用`/♾️ | 百分比 | 同字号弱色「(重置: …)」,♾️ 与 `已用` 同列左对齐,弱色括注整列左对齐)、虚线分隔的分模型用量行(名称左对齐、token 数右对齐)。窗口按 MCP(月)→ 5 小时 → 每月 → 每周固定排序;接口未下发周窗口时显示「每周额度 · ♾️(无限)」占位,真实周窗口数据一旦下发即自动替换占位。同一时间至多展开一个详情;点击面板外或按 Esc 关闭。
11
+ - GitHub Copilot 摘要行仅在 GitHub 返回有限、有效的 `remaining` 且带 `percent_remaining` 时显示;行尾等宽小字显示本期 credits 消耗的紧凑值。点击在侧栏右侧弹出精简详情(标签左对齐、数值右对齐的三行):可用额度、本期已用(credits 消耗 + 弱化的已用百分比)、重置时间。`credits_used` 绝不用于推算余额,缺失有效百分比时该行不渲染。
12
+ - 卡片顶部不显示全局更新时间;仅在等待首个快照或刷新失败时显示一行状态文字(读取中 / 刷新失败 / 查询失败)。
12
13
  - 今日 tokens / 调用次数按宿主本地时区的自然日(`00:00:00 → 当前时刻`)查询;不带滚动窗口语义。
13
14
  - 凭据缺失的 provider 不渲染摘要行(两者皆缺时整卡隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记。
14
- - 宿主默认每 60 秒探测(窗口 + 用量 + Copilot);profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停;刷新按钮可立即请求宿主探测。
15
+ - 宿主默认每 60 秒探测(窗口 + 用量 + Copilot);profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停;点击行首 provider 名称可立即请求宿主探测。
15
16
 
16
17
  ## 兼容与配置
17
18
 
package/lib/client.js CHANGED
@@ -91,80 +91,97 @@ var COPY = {
91
91
  title: "\u5957\u9910\u76D1\u63A7",
92
92
  refresh: "\u5237\u65B0",
93
93
  loading: "\u8BFB\u53D6\u4E2D",
94
- updated: "\u66F4\u65B0",
95
94
  failed: "\u67E5\u8BE2\u5931\u8D25",
96
95
  stale: "\u6570\u636E\u53EF\u80FD\u5DF2\u8FC7\u671F",
97
96
  requestFailed: "\u5237\u65B0\u5931\u8D25\uFF0C\u663E\u793A\u4E0A\u6B21\u6210\u529F\u6570\u636E",
98
97
  available: "\u53EF\u7528\u989D\u5EA6",
99
98
  used: "\u672C\u671F\u5DF2\u7528",
100
99
  reset: "\u91CD\u7F6E",
101
- todayTokens: "\u4ECA\u65E5 tokens",
102
- todayCalls: "\u4ECA\u65E5\u8C03\u7528",
100
+ resetTime: "\u91CD\u7F6E\u65F6\u95F4",
101
+ windowUsed: "\u5DF2\u7528",
102
+ todayTokens: "\u4ECA\u65E5 Tokens",
103
+ todayCalls: "\u8C03\u7528\u6B21\u6570",
104
+ weekUnlimited: "\u6BCF\u5468\u989D\u5EA6",
105
+ unlimited: "\u267E\uFE0F",
106
+ unlimitedNote: "\uFF08\u65E0\u9650\uFF09",
103
107
  windows: {
104
- "5h": "5 \u5C0F\u65F6\u7A97\u53E3 \xB7 \u5DF2\u7528",
105
- week: "\u6BCF\u5468\u7A97\u53E3 \xB7 \u5DF2\u7528",
106
- month: "\u6BCF\u6708\u7A97\u53E3 \xB7 \u5DF2\u7528",
107
- mcp: "MCP\uFF08\u6708\uFF09\xB7 \u5DF2\u7528"
108
+ "5h": "5 \u5C0F\u65F6\u989D\u5EA6",
109
+ week: "\u6BCF\u5468\u989D\u5EA6",
110
+ month: "\u6BCF\u6708\u989D\u5EA6",
111
+ mcp: "MCP\uFF08\u6708\uFF09"
108
112
  }
109
113
  },
110
114
  en: {
111
115
  title: "Quota Watch",
112
116
  refresh: "Refresh",
113
117
  loading: "Loading",
114
- updated: "Updated",
115
118
  failed: "Query failed",
116
119
  stale: "Data may be stale",
117
120
  requestFailed: "Refresh failed; showing last successful data",
118
121
  available: "Available credits",
119
122
  used: "Used this cycle",
120
123
  reset: "Resets",
124
+ resetTime: "Reset time",
125
+ windowUsed: "used",
121
126
  todayTokens: "Today tokens",
122
- todayCalls: "Calls today",
127
+ todayCalls: "Calls",
128
+ weekUnlimited: "Weekly quota",
129
+ unlimited: "\u267E\uFE0F",
130
+ unlimitedNote: " (unlimited)",
123
131
  windows: {
124
- "5h": "5-hour window \xB7 used",
125
- week: "Weekly window \xB7 used",
126
- month: "Monthly window \xB7 used",
127
- mcp: "MCP (month) \xB7 used"
132
+ "5h": "5-hour quota",
133
+ week: "Weekly quota",
134
+ month: "Monthly quota",
135
+ mcp: "MCP (month)"
128
136
  }
129
137
  }
130
138
  };
131
139
  var STYLE_TEXT = `
132
140
  [data-dsh-quota-watch-card] { box-sizing: border-box; width: 100%; margin: 0 0 8px; color: var(--dsw-alias-label-primary, inherit); font: inherit; }
133
141
  [data-dsh-quota-watch-card] *, [data-dsh-quota-watch-card] *::before, [data-dsh-quota-watch-card] *::after { box-sizing: border-box; }
134
- .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
135
- .dqw-head { display: flex; align-items: center; gap: 4px; min-width: 0; }
136
- .dqw-title { flex: 1; min-width: 0; margin: 0; font-size: 12px; font-weight: 600; line-height: 18px; }
137
- .dqw-button { border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11px; line-height: 18px; padding: 1px 4px; }
138
- .dqw-button:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.16)); }
139
- .dqw-button:disabled { cursor: wait; opacity: .6; }
142
+ .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 5px 8px; display: flex; flex-direction: column; gap: 2px; }
140
143
  .dqw-meta { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
141
144
  .dqw-body { display: flex; flex-direction: column; }
142
- .dqw-row-summary { display: flex; align-items: center; gap: 6px; padding: 7px 6px; margin: 1px -2px 2px; border-radius: 8px; }
145
+ .dqw-row-summary { display: flex; align-items: center; gap: 6px; padding: 3px 6px; margin: 0 -2px; border-radius: 8px; }
143
146
  .dqw-row-summary[data-expandable] { cursor: pointer; }
144
147
  .dqw-row-summary[data-expandable]:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.12)); }
145
148
  .dqw-row-summary:focus-visible { outline: 1px solid var(--dsw-alias-button-primary-fill, #5b8def); outline-offset: -1px; }
146
149
  .dqw-row-summary.is-error { cursor: default; }
147
150
  .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
151
+ .dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
152
+ .dqw-label[data-action="refresh"]:hover { opacity: .75; }
148
153
  .dqw-bar { display: block; flex: 1; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
149
154
  .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
150
155
  .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
151
156
  .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
152
157
  .dqw-pct { font-size: 10.5px; line-height: 14px; font-variant-numeric: tabular-nums; width: 30px; text-align: right; flex: none; }
153
- .dqw-extra { font-size: 10px; line-height: 14px; opacity: .72; font-variant-numeric: tabular-nums; flex: none; }
158
+ .dqw-extra { font-size: 10px; line-height: 14px; opacity: .72; font-variant-numeric: tabular-nums; flex: none; min-width: 34px; text-align: right; }
154
159
  .dqw-chev { font-size: 9px; line-height: 14px; opacity: .6; flex: none; width: 10px; text-align: center; }
155
160
  .dqw-stale-mark { flex: none; font-size: 9px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); cursor: help; }
156
161
  .dqw-errtext { font-size: 10px; line-height: 14px; opacity: .7; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
157
- .dqw-detail { padding: 2px 6px 6px; display: flex; flex-direction: column; gap: 7px; }
162
+ .dqw-pop { position: fixed; z-index: 2147483000; min-width: 232px; max-width: 320px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
163
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-pop { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
164
+ .dqw-pop[hidden] { display: none; }
165
+ .dqw-pop *, .dqw-pop *::before, .dqw-pop *::after { box-sizing: border-box; }
158
166
  .dqw-bignums { display: flex; gap: 6px; }
159
- .dqw-big { flex: 1; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); border-radius: 8px; padding: 6px 8px; }
160
- .dqw-big .dqw-big-value { font-size: 16px; font-weight: 650; line-height: 20px; font-variant-numeric: tabular-nums; }
167
+ .dqw-big { flex: 1; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); border-radius: 8px; padding: 5px 8px; }
168
+ .dqw-big .dqw-big-value { font-size: 15px; font-weight: 650; line-height: 19px; font-variant-numeric: tabular-nums; }
161
169
  .dqw-big .dqw-big-label { font-size: 9.5px; line-height: 13px; opacity: .65; }
162
- .dqw-row { display: flex; flex-direction: column; gap: 2px; }
163
- .dqw-row-head { display: flex; justify-content: space-between; gap: 5px; font-size: 10px; line-height: 14px; }
164
170
  .dqw-muted { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
165
- .dqw-models { margin: 0; font-size: 10px; line-height: 15px; opacity: .8; border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; }
171
+ .dqw-models { border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 3px; }
172
+ .dqw-model-row { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 10.5px; line-height: 15px; font-variant-numeric: tabular-nums; }
173
+ .dqw-model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .85; }
174
+ .dqw-model-value { flex: none; font-weight: 600; }
175
+ .dqw-kv { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
176
+ .dqw-kv-label { flex: none; opacity: .65; }
177
+ .dqw-kv-value { min-width: 0; text-align: right; font-weight: 600; }
178
+ .dqw-kv-sub { font-weight: 400; opacity: .55; }
179
+ .dqw-wins { display: grid; grid-template-columns: max-content max-content max-content max-content 1fr; column-gap: 6px; row-gap: 3px; align-items: baseline; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
180
+ .dqw-win-name { opacity: .65; }
181
+ .dqw-win-mid { opacity: .65; white-space: nowrap; }
182
+ .dqw-win-used { opacity: .65; white-space: nowrap; }
183
+ .dqw-win-val { min-width: 0; font-weight: 600; }
166
184
  .dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
167
- .dqw-credit { margin: 0; font-size: 10px; line-height: 14px; font-variant-numeric: tabular-nums; }
168
185
  `;
169
186
  function localeFor(win) {
170
187
  return String(win?.navigator?.language ?? "zh").toLowerCase().startsWith("zh") ? "zh" : "en";
@@ -201,23 +218,10 @@ function text(doc, tag, className, value) {
201
218
  node.textContent = String(value ?? "");
202
219
  return node;
203
220
  }
204
- function progress(doc, label, percent, locale) {
221
+ function kvRow(doc, label, value) {
205
222
  const row = doc.createElement("div");
206
- row.className = "dqw-row";
207
- const head = doc.createElement("div");
208
- head.className = "dqw-row-head";
209
- head.append(text(doc, "span", "", label), text(doc, "span", "", formatPercent(percent, locale)));
210
- const bar = doc.createElement("span");
211
- bar.className = "dqw-bar";
212
- bar.setAttribute("role", "progressbar");
213
- bar.setAttribute("aria-valuemin", "0");
214
- bar.setAttribute("aria-valuemax", "100");
215
- bar.setAttribute("aria-valuenow", String(Math.max(0, Math.min(100, percent))));
216
- const fill = doc.createElement("span");
217
- fill.className = `dqw-bar-fill${percent > 95 ? " danger" : percent > 80 ? " warn" : ""}`;
218
- fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
219
- bar.append(fill);
220
- row.append(head, bar);
223
+ row.className = "dqw-kv";
224
+ row.append(text(doc, "span", "dqw-kv-label", label), text(doc, "span", "dqw-kv-value", value));
221
225
  return row;
222
226
  }
223
227
  function glmRowModel(provider) {
@@ -248,7 +252,8 @@ function copilotRowModel(provider) {
248
252
  label: "Copilot",
249
253
  percent: used,
250
254
  stale: provider.status === "stale",
251
- error: provider.error
255
+ error: provider.error,
256
+ extra: typeof quota.creditsUsed === "number" && quota.creditsUsed >= 0 ? quota.creditsUsed : void 0
252
257
  };
253
258
  }
254
259
  function footArea(doc) {
@@ -284,22 +289,18 @@ function mountQuotaCard({
284
289
  const card = doc.createElement("section");
285
290
  card.className = "dqw-card";
286
291
  card.setAttribute("aria-label", copy.title);
287
- const header = doc.createElement("header");
288
- header.className = "dqw-head";
289
- header.append(text(doc, "h3", "dqw-title", copy.title));
290
- const refreshButton = text(doc, "button", "dqw-button", "\u21BB");
291
- refreshButton.type = "button";
292
- refreshButton.title = copy.refresh;
293
- refreshButton.setAttribute("aria-label", copy.refresh);
294
- refreshButton.dataset.action = "refresh";
295
- header.append(refreshButton);
296
292
  const body = doc.createElement("div");
297
293
  body.className = "dqw-body";
298
294
  const lastUpdated = text(doc, "p", "dqw-meta", copy.loading);
299
295
  lastUpdated.dataset.role = "updated";
300
296
  body.append(lastUpdated);
301
- card.append(header, body);
297
+ card.append(body);
302
298
  container.append(style, card);
299
+ const pop = doc.createElement("div");
300
+ pop.className = "dqw-pop";
301
+ pop.dataset.dshQuotaWatchPop = "";
302
+ pop.hidden = true;
303
+ doc.body.append(pop);
303
304
  let snapshot;
304
305
  let requestSequence = 0;
305
306
  let timer;
@@ -307,12 +308,15 @@ function mountQuotaCard({
307
308
  let queuedPlace = false;
308
309
  let openKey;
309
310
  const fillClass = (percent) => percent > 95 ? "dqw-bar-fill danger" : percent > 80 ? "dqw-bar-fill warn" : "dqw-bar-fill";
310
- const renderProvider = (key, model, provider) => {
311
+ const renderProvider = (key, model) => {
311
312
  if (model === null) return null;
312
313
  const row = doc.createElement("div");
313
314
  row.className = "dqw-row-summary";
314
315
  row.dataset.dshQuotaWatchRow = key;
315
- row.append(text(doc, "span", "dqw-label", model.label));
316
+ const label = text(doc, "span", "dqw-label", model.label);
317
+ label.dataset.action = "refresh";
318
+ label.title = `${model.label} \xB7 ${copy.refresh}`;
319
+ row.append(label);
316
320
  if (model.kind === "error") {
317
321
  row.classList.add("is-error");
318
322
  row.append(text(doc, "span", "dqw-errtext", model.error ? `${copy.failed}: ${model.error}` : copy.failed));
@@ -347,11 +351,7 @@ function mountQuotaCard({
347
351
  row.append(extra);
348
352
  }
349
353
  row.append(text(doc, "span", "dqw-chev", openKey === key ? "\u25BE" : "\u25B8"));
350
- const detail = doc.createElement("div");
351
- detail.className = "dqw-detail";
352
- detail.dataset.dshQuotaWatchDetail = key;
353
- if (openKey === key) renderDetail(detail, key, provider);
354
- return [row, detail];
354
+ return [row];
355
355
  };
356
356
  function renderDetail(detail, key, provider) {
357
357
  detail.replaceChildren();
@@ -376,42 +376,124 @@ function mountQuotaCard({
376
376
  );
377
377
  numbers.append(tokensBig, callsBig);
378
378
  detail.append(numbers);
379
- for (const window of provider?.plan?.windows ?? []) {
380
- const label = copy.windows[window?.key] ?? window?.key;
381
- if (typeof window?.percent === "number" && Number.isFinite(window.percent)) {
382
- detail.append(progress(doc, label, window.percent, locale));
383
- } else {
384
- detail.append(text(doc, "p", "dqw-muted", label));
379
+ const raw = Array.isArray(provider?.plan?.windows) ? provider.plan.windows : [];
380
+ const order = { mcp: 0, "5h": 1, month: 2, week: 3 };
381
+ const sorted = [...raw].sort((a, b) => (order[a?.key] ?? 99) - (order[b?.key] ?? 99));
382
+ const items = [];
383
+ let weekPlaced = false;
384
+ for (const window of sorted) {
385
+ if (!weekPlaced && (order[window?.key] ?? 99) > order.week) {
386
+ items.push({ placeholder: true });
387
+ weekPlaced = true;
388
+ }
389
+ if (window?.key === "week") weekPlaced = true;
390
+ items.push({ window });
391
+ }
392
+ if (!weekPlaced) items.push({ placeholder: true });
393
+ const wins = doc.createElement("div");
394
+ wins.className = "dqw-wins";
395
+ for (const item of items) {
396
+ if (item.placeholder) {
397
+ wins.append(
398
+ text(doc, "span", "dqw-win-name", copy.weekUnlimited),
399
+ text(doc, "span", "dqw-win-mid", "\xB7"),
400
+ text(doc, "span", "dqw-win-used", copy.unlimited),
401
+ text(doc, "span", ""),
402
+ text(doc, "span", "dqw-kv-sub", copy.unlimitedNote)
403
+ );
404
+ continue;
385
405
  }
386
- const reset2 = formatTime(window?.resetsAt, locale);
387
- if (reset2) detail.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset2}`));
406
+ const window = item.window;
407
+ const percent = typeof window?.percent === "number" && Number.isFinite(window.percent) ? window.percent : void 0;
408
+ const reset = formatTime(window?.resetsAt, locale);
409
+ wins.append(text(doc, "span", "dqw-win-name", copy.windows[window?.key] ?? window?.key));
410
+ wins.append(text(doc, "span", "dqw-win-mid", "\xB7"));
411
+ wins.append(text(doc, "span", "dqw-win-used", copy.windowUsed));
412
+ wins.append(text(doc, "span", "dqw-win-val", percent !== void 0 ? formatPercent(percent, locale) : "\u2014"));
413
+ wins.append(text(doc, "span", "dqw-kv-sub", reset ? `\uFF08${copy.reset}: ${reset}\uFF09` : ""));
414
+ }
415
+ detail.append(wins);
416
+ if (Array.isArray(usage?.models) && usage.models.length > 0) {
417
+ const models = doc.createElement("div");
418
+ models.className = "dqw-models";
419
+ for (const model of usage.models) {
420
+ const row = doc.createElement("div");
421
+ row.className = "dqw-model-row";
422
+ row.append(
423
+ text(doc, "span", "dqw-model-name", model?.name ?? "\u2014"),
424
+ text(doc, "span", "dqw-model-value", formatCompact(model?.tokens, locale))
425
+ );
426
+ models.append(row);
427
+ }
428
+ detail.append(models);
388
429
  }
389
- const models = Array.isArray(usage?.models) && usage.models.length > 0 ? usage.models.map((model) => `${model.name} ${formatCompact(model.tokens, locale)}`).join(" \xB7 ") : void 0;
390
- if (models) detail.append(text(doc, "p", "dqw-models", models));
391
- const updated = formatTime(provider?.updatedAt ? new Date(provider.updatedAt).toISOString() : "", locale);
392
- if (updated) detail.append(text(doc, "p", "dqw-meta", `${copy.updated}: ${updated}`));
393
430
  return;
394
431
  }
395
432
  const quota = provider?.quota;
396
433
  if (quota?.balanceAvailable === true) {
397
- detail.append(text(doc, "p", "dqw-credit", `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`));
434
+ detail.append(kvRow(doc, copy.available, `${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`));
398
435
  const used = remainingToUsed(quota.percentRemaining);
399
- if (used !== void 0) detail.append(progress(doc, copy.used, used, locale));
400
436
  if (typeof quota.creditsUsed === "number" && quota.creditsUsed >= 0) {
401
- detail.append(text(doc, "p", "dqw-muted", `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`));
437
+ const row = doc.createElement("div");
438
+ row.className = "dqw-kv";
439
+ const value = doc.createElement("span");
440
+ value.className = "dqw-kv-value";
441
+ value.append(text(doc, "span", "", formatNumber(quota.creditsUsed, locale)));
442
+ if (used !== void 0) value.append(text(doc, "span", "dqw-kv-sub", ` \xB7 ${formatPercent(used, locale)}`));
443
+ row.append(text(doc, "span", "dqw-kv-label", copy.used), value);
444
+ detail.append(row);
402
445
  }
446
+ const reset = formatTime(quota?.resetsAt, locale);
447
+ if (reset) detail.append(kvRow(doc, copy.resetTime, reset));
403
448
  } else {
404
449
  detail.append(text(doc, "p", "dqw-muted", copy.stale));
405
450
  }
406
- const reset = formatTime(quota?.resetsAt, locale);
407
- if (reset) detail.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset}`));
408
451
  }
452
+ const positionPop = () => {
453
+ if (pop.hidden || openKey === void 0) return;
454
+ const row = body.querySelector(`[data-dsh-quota-watch-row="${openKey}"]`);
455
+ if (!row) return;
456
+ const rect = row.getBoundingClientRect();
457
+ const viewWidth = win.innerWidth ?? 1024;
458
+ const viewHeight = win.innerHeight ?? 768;
459
+ const width = pop.offsetWidth || 240;
460
+ const height = pop.offsetHeight || 160;
461
+ let left = rect.right + 8;
462
+ if (left + width > viewWidth - 8) left = rect.left - width - 8;
463
+ if (left + width > viewWidth - 8) left = viewWidth - width - 8;
464
+ left = Math.max(8, left);
465
+ const top = Math.min(Math.max(rect.top, 8), Math.max(8, viewHeight - height - 8));
466
+ pop.style.left = `${Math.round(left)}px`;
467
+ pop.style.top = `${Math.round(top)}px`;
468
+ };
469
+ const syncPop = () => {
470
+ if (openKey === void 0 || disposed) {
471
+ if (!pop.hidden) {
472
+ pop.hidden = true;
473
+ pop.replaceChildren();
474
+ delete pop.dataset.dshQuotaWatchDetail;
475
+ }
476
+ return;
477
+ }
478
+ const provider = (snapshot?.providers ?? []).find((item) => item?.key === openKey);
479
+ const row = body.querySelector(`[data-dsh-quota-watch-row="${openKey}"]`);
480
+ if (!provider || !row || !row.hasAttribute("data-expandable")) {
481
+ openKey = void 0;
482
+ syncPop();
483
+ return;
484
+ }
485
+ renderDetail(pop, openKey, provider);
486
+ pop.dataset.dshQuotaWatchDetail = openKey;
487
+ pop.setAttribute("aria-label", openKey === "glm" ? "GLM" : "Copilot");
488
+ pop.hidden = false;
489
+ positionPop();
490
+ };
409
491
  const renderAll = () => {
410
492
  const providers = snapshot?.providers ?? [];
411
493
  const glm = providers.find((provider) => provider?.key === "glm");
412
494
  const copilot = providers.find((provider) => provider?.key === "copilot");
413
- const glmParts = renderProvider("glm", glmRowModel(glm), glm);
414
- const copilotParts = renderProvider("copilot", copilotRowModel(copilot), copilot);
495
+ const glmParts = renderProvider("glm", glmRowModel(glm));
496
+ const copilotParts = renderProvider("copilot", copilotRowModel(copilot));
415
497
  const fragment = doc.createDocumentFragment();
416
498
  fragment.append(lastUpdated);
417
499
  if (glmParts) fragment.append(...glmParts);
@@ -419,37 +501,37 @@ function mountQuotaCard({
419
501
  body.replaceChildren(fragment);
420
502
  container.hidden = glmParts === null && copilotParts === null;
421
503
  const updated = formatTime(snapshot?.updatedAt ? new Date(snapshot.updatedAt).toISOString() : "", locale);
422
- lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading;
504
+ if (updated) {
505
+ lastUpdated.hidden = true;
506
+ } else {
507
+ lastUpdated.hidden = false;
508
+ lastUpdated.textContent = copy.loading;
509
+ }
510
+ syncPop();
423
511
  };
424
512
  const render = (next) => {
425
513
  snapshot = next;
426
514
  renderAll();
427
515
  };
428
516
  const showTransportError = () => {
429
- if (snapshot) lastUpdated.textContent = copy.requestFailed;
430
- else lastUpdated.textContent = copy.failed;
517
+ lastUpdated.hidden = false;
518
+ lastUpdated.textContent = snapshot ? copy.requestFailed : copy.failed;
431
519
  };
432
520
  const poll = async (force) => {
433
521
  if (disposed) return;
434
522
  const sequence = ++requestSequence;
435
- refreshButton.disabled = force === true;
436
523
  try {
437
524
  const next = await fetchJson(fetchImpl, force ? CLIENT_ROUTES.refresh : CLIENT_ROUTES.overview, force ? "POST" : "GET");
438
525
  if (sequence === requestSequence && !disposed) render(next);
439
526
  } catch {
440
527
  if (sequence === requestSequence && !disposed) showTransportError();
441
- } finally {
442
- if (!disposed && sequence === requestSequence) refreshButton.disabled = false;
443
528
  }
444
529
  };
445
530
  const place = () => {
446
531
  const foot = footArea(doc);
447
532
  if (!foot) return;
448
- const settings = foot.querySelector('[class*="settingsArea"]');
449
- if (settings) {
450
- if (container.parentElement !== foot || container.nextElementSibling !== settings) foot.insertBefore(container, settings);
451
- } else if (container.parentElement !== foot || foot.lastElementChild !== container) {
452
- foot.append(container);
533
+ if (container.parentElement !== foot || foot.firstElementChild !== container) {
534
+ foot.insertBefore(container, foot.firstChild);
453
535
  }
454
536
  };
455
537
  const schedulePlace = () => {
@@ -465,9 +547,9 @@ function mountQuotaCard({
465
547
  place();
466
548
  const startPolling = () => {
467
549
  if (timer !== void 0 || doc.visibilityState === "hidden" || disposed) return;
468
- void poll(false);
550
+ void poll();
469
551
  timer = win.setInterval(() => {
470
- void poll(false);
552
+ void poll();
471
553
  }, pollIntervalMs);
472
554
  };
473
555
  const stopPolling = () => {
@@ -478,14 +560,15 @@ function mountQuotaCard({
478
560
  if (doc.visibilityState === "hidden") stopPolling();
479
561
  else startPolling();
480
562
  };
481
- const onRefresh = () => {
482
- void poll(true);
483
- };
484
563
  const toggleRow = (key) => {
485
564
  openKey = openKey === key ? void 0 : key;
486
565
  renderAll();
487
566
  };
488
567
  const onBodyClick = (event) => {
568
+ if (event.target?.closest?.('[data-action="refresh"]')) {
569
+ void poll(true);
570
+ return;
571
+ }
489
572
  const row = event.target?.closest?.("[data-dsh-quota-watch-row][data-expandable]");
490
573
  if (row) toggleRow(row.dataset.dshQuotaWatchRow);
491
574
  };
@@ -497,20 +580,46 @@ function mountQuotaCard({
497
580
  toggleRow(row.dataset.dshQuotaWatchRow);
498
581
  }
499
582
  };
500
- refreshButton.addEventListener("click", onRefresh);
583
+ const onDocPointerDown = (event) => {
584
+ if (openKey === void 0) return;
585
+ const target = event.target;
586
+ if (pop.contains(target) || target?.closest?.("[data-dsh-quota-watch-row]") || target?.closest?.(CARD_SELECTOR)) return;
587
+ openKey = void 0;
588
+ renderAll();
589
+ };
590
+ const onDocScroll = () => {
591
+ positionPop();
592
+ };
593
+ const onWinResize = () => {
594
+ positionPop();
595
+ };
596
+ const onDocKeydown = (event) => {
597
+ if (event.key === "Escape" && openKey !== void 0) {
598
+ openKey = void 0;
599
+ renderAll();
600
+ }
601
+ };
501
602
  body.addEventListener("click", onBodyClick);
502
603
  body.addEventListener("keydown", onBodyKeydown);
503
604
  doc.addEventListener("visibilitychange", onVisibilityChange);
605
+ doc.addEventListener("pointerdown", onDocPointerDown, true);
606
+ doc.addEventListener("scroll", onDocScroll, true);
607
+ doc.addEventListener("keydown", onDocKeydown);
608
+ win.addEventListener("resize", onWinResize);
504
609
  startPolling();
505
610
  return () => {
506
611
  disposed = true;
507
612
  stopPolling();
508
613
  observer.disconnect();
509
614
  doc.removeEventListener("visibilitychange", onVisibilityChange);
510
- refreshButton.removeEventListener("click", onRefresh);
615
+ doc.removeEventListener("pointerdown", onDocPointerDown, true);
616
+ doc.removeEventListener("scroll", onDocScroll, true);
617
+ doc.removeEventListener("keydown", onDocKeydown);
618
+ win.removeEventListener("resize", onWinResize);
511
619
  body.removeEventListener("click", onBodyClick);
512
620
  body.removeEventListener("keydown", onBodyKeydown);
513
621
  container.remove();
622
+ pop.remove();
514
623
  };
515
624
  }
516
625
  function apply(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.0.3",
3
+ "version": "0.0.10",
4
4
  "description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
5
5
  "type": "module",
6
6
  "main": "./src/host.mjs",
package/src/client.mjs CHANGED
@@ -12,40 +12,48 @@ const COPY = {
12
12
  title: '套餐监控',
13
13
  refresh: '刷新',
14
14
  loading: '读取中',
15
- updated: '更新',
16
15
  failed: '查询失败',
17
16
  stale: '数据可能已过期',
18
17
  requestFailed: '刷新失败,显示上次成功数据',
19
18
  available: '可用额度',
20
19
  used: '本期已用',
21
20
  reset: '重置',
22
- todayTokens: '今日 tokens',
23
- todayCalls: '今日调用',
21
+ resetTime: '重置时间',
22
+ windowUsed: '已用',
23
+ todayTokens: '今日 Tokens',
24
+ todayCalls: '调用次数',
25
+ weekUnlimited: '每周额度',
26
+ unlimited: '♾️',
27
+ unlimitedNote: '(无限)',
24
28
  windows: {
25
- '5h': '5 小时窗口 · 已用',
26
- week: '每周窗口 · 已用',
27
- month: '每月窗口 · 已用',
28
- mcp: 'MCP(月)· 已用',
29
+ '5h': '5 小时额度',
30
+ week: '每周额度',
31
+ month: '每月额度',
32
+ mcp: 'MCP(月)',
29
33
  },
30
34
  },
31
35
  en: {
32
36
  title: 'Quota Watch',
33
37
  refresh: 'Refresh',
34
38
  loading: 'Loading',
35
- updated: 'Updated',
36
39
  failed: 'Query failed',
37
40
  stale: 'Data may be stale',
38
41
  requestFailed: 'Refresh failed; showing last successful data',
39
42
  available: 'Available credits',
40
43
  used: 'Used this cycle',
41
44
  reset: 'Resets',
45
+ resetTime: 'Reset time',
46
+ windowUsed: 'used',
42
47
  todayTokens: 'Today tokens',
43
- todayCalls: 'Calls today',
48
+ todayCalls: 'Calls',
49
+ weekUnlimited: 'Weekly quota',
50
+ unlimited: '♾️',
51
+ unlimitedNote: ' (unlimited)',
44
52
  windows: {
45
- '5h': '5-hour window · used',
46
- week: 'Weekly window · used',
47
- month: 'Monthly window · used',
48
- mcp: 'MCP (month) · used',
53
+ '5h': '5-hour quota',
54
+ week: 'Weekly quota',
55
+ month: 'Monthly quota',
56
+ mcp: 'MCP (month)',
49
57
  },
50
58
  },
51
59
  }
@@ -53,40 +61,49 @@ const COPY = {
53
61
  const STYLE_TEXT = `
54
62
  [data-dsh-quota-watch-card] { box-sizing: border-box; width: 100%; margin: 0 0 8px; color: var(--dsw-alias-label-primary, inherit); font: inherit; }
55
63
  [data-dsh-quota-watch-card] *, [data-dsh-quota-watch-card] *::before, [data-dsh-quota-watch-card] *::after { box-sizing: border-box; }
56
- .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
57
- .dqw-head { display: flex; align-items: center; gap: 4px; min-width: 0; }
58
- .dqw-title { flex: 1; min-width: 0; margin: 0; font-size: 12px; font-weight: 600; line-height: 18px; }
59
- .dqw-button { border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11px; line-height: 18px; padding: 1px 4px; }
60
- .dqw-button:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.16)); }
61
- .dqw-button:disabled { cursor: wait; opacity: .6; }
64
+ .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 5px 8px; display: flex; flex-direction: column; gap: 2px; }
62
65
  .dqw-meta { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
63
66
  .dqw-body { display: flex; flex-direction: column; }
64
- .dqw-row-summary { display: flex; align-items: center; gap: 6px; padding: 7px 6px; margin: 1px -2px 2px; border-radius: 8px; }
67
+ .dqw-row-summary { display: flex; align-items: center; gap: 6px; padding: 3px 6px; margin: 0 -2px; border-radius: 8px; }
65
68
  .dqw-row-summary[data-expandable] { cursor: pointer; }
66
69
  .dqw-row-summary[data-expandable]:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.12)); }
67
70
  .dqw-row-summary:focus-visible { outline: 1px solid var(--dsw-alias-button-primary-fill, #5b8def); outline-offset: -1px; }
68
71
  .dqw-row-summary.is-error { cursor: default; }
69
72
  .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
73
+ .dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
74
+ .dqw-label[data-action="refresh"]:hover { opacity: .75; }
70
75
  .dqw-bar { display: block; flex: 1; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
71
76
  .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
72
77
  .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
73
78
  .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
74
79
  .dqw-pct { font-size: 10.5px; line-height: 14px; font-variant-numeric: tabular-nums; width: 30px; text-align: right; flex: none; }
75
- .dqw-extra { font-size: 10px; line-height: 14px; opacity: .72; font-variant-numeric: tabular-nums; flex: none; }
80
+ .dqw-extra { font-size: 10px; line-height: 14px; opacity: .72; font-variant-numeric: tabular-nums; flex: none; min-width: 34px; text-align: right; }
76
81
  .dqw-chev { font-size: 9px; line-height: 14px; opacity: .6; flex: none; width: 10px; text-align: center; }
77
82
  .dqw-stale-mark { flex: none; font-size: 9px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); cursor: help; }
78
83
  .dqw-errtext { font-size: 10px; line-height: 14px; opacity: .7; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
- .dqw-detail { padding: 2px 6px 6px; display: flex; flex-direction: column; gap: 7px; }
84
+ .dqw-pop { position: fixed; z-index: 2147483000; min-width: 232px; max-width: 320px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
85
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-pop { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
86
+ .dqw-pop[hidden] { display: none; }
87
+ .dqw-pop *, .dqw-pop *::before, .dqw-pop *::after { box-sizing: border-box; }
80
88
  .dqw-bignums { display: flex; gap: 6px; }
81
- .dqw-big { flex: 1; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); border-radius: 8px; padding: 6px 8px; }
82
- .dqw-big .dqw-big-value { font-size: 16px; font-weight: 650; line-height: 20px; font-variant-numeric: tabular-nums; }
89
+ .dqw-big { flex: 1; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); border-radius: 8px; padding: 5px 8px; }
90
+ .dqw-big .dqw-big-value { font-size: 15px; font-weight: 650; line-height: 19px; font-variant-numeric: tabular-nums; }
83
91
  .dqw-big .dqw-big-label { font-size: 9.5px; line-height: 13px; opacity: .65; }
84
- .dqw-row { display: flex; flex-direction: column; gap: 2px; }
85
- .dqw-row-head { display: flex; justify-content: space-between; gap: 5px; font-size: 10px; line-height: 14px; }
86
92
  .dqw-muted { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
87
- .dqw-models { margin: 0; font-size: 10px; line-height: 15px; opacity: .8; border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; }
93
+ .dqw-models { border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 3px; }
94
+ .dqw-model-row { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 10.5px; line-height: 15px; font-variant-numeric: tabular-nums; }
95
+ .dqw-model-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .85; }
96
+ .dqw-model-value { flex: none; font-weight: 600; }
97
+ .dqw-kv { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
98
+ .dqw-kv-label { flex: none; opacity: .65; }
99
+ .dqw-kv-value { min-width: 0; text-align: right; font-weight: 600; }
100
+ .dqw-kv-sub { font-weight: 400; opacity: .55; }
101
+ .dqw-wins { display: grid; grid-template-columns: max-content max-content max-content max-content 1fr; column-gap: 6px; row-gap: 3px; align-items: baseline; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
102
+ .dqw-win-name { opacity: .65; }
103
+ .dqw-win-mid { opacity: .65; white-space: nowrap; }
104
+ .dqw-win-used { opacity: .65; white-space: nowrap; }
105
+ .dqw-win-val { min-width: 0; font-weight: 600; }
88
106
  .dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
89
- .dqw-credit { margin: 0; font-size: 10px; line-height: 14px; font-variant-numeric: tabular-nums; }
90
107
  `
91
108
 
92
109
  function localeFor(win) {
@@ -130,23 +147,11 @@ function text(doc, tag, className, value) {
130
147
  return node
131
148
  }
132
149
 
133
- function progress(doc, label, percent, locale) {
150
+ /** Aligned label/value line used by the simplified Copilot detail. */
151
+ function kvRow(doc, label, value) {
134
152
  const row = doc.createElement('div')
135
- row.className = 'dqw-row'
136
- const head = doc.createElement('div')
137
- head.className = 'dqw-row-head'
138
- head.append(text(doc, 'span', '', label), text(doc, 'span', '', formatPercent(percent, locale)))
139
- const bar = doc.createElement('span')
140
- bar.className = 'dqw-bar'
141
- bar.setAttribute('role', 'progressbar')
142
- bar.setAttribute('aria-valuemin', '0')
143
- bar.setAttribute('aria-valuemax', '100')
144
- bar.setAttribute('aria-valuenow', String(Math.max(0, Math.min(100, percent))))
145
- const fill = doc.createElement('span')
146
- fill.className = `dqw-bar-fill${percent > 95 ? ' danger' : percent > 80 ? ' warn' : ''}`
147
- fill.style.width = `${Math.max(0, Math.min(100, percent))}%`
148
- bar.append(fill)
149
- row.append(head, bar)
153
+ row.className = 'dqw-kv'
154
+ row.append(text(doc, 'span', 'dqw-kv-label', label), text(doc, 'span', 'dqw-kv-value', value))
150
155
  return row
151
156
  }
152
157
 
@@ -184,6 +189,7 @@ function copilotRowModel(provider) {
184
189
  percent: used,
185
190
  stale: provider.status === 'stale',
186
191
  error: provider.error,
192
+ extra: typeof quota.creditsUsed === 'number' && quota.creditsUsed >= 0 ? quota.creditsUsed : undefined,
187
193
  }
188
194
  }
189
195
 
@@ -222,22 +228,20 @@ export function mountQuotaCard({
222
228
  const card = doc.createElement('section')
223
229
  card.className = 'dqw-card'
224
230
  card.setAttribute('aria-label', copy.title)
225
- const header = doc.createElement('header')
226
- header.className = 'dqw-head'
227
- header.append(text(doc, 'h3', 'dqw-title', copy.title))
228
- const refreshButton = text(doc, 'button', 'dqw-button', '↻')
229
- refreshButton.type = 'button'
230
- refreshButton.title = copy.refresh
231
- refreshButton.setAttribute('aria-label', copy.refresh)
232
- refreshButton.dataset.action = 'refresh'
233
- header.append(refreshButton)
234
231
  const body = doc.createElement('div')
235
232
  body.className = 'dqw-body'
233
+ // Status line doubles as the transport-error surface; hidden once data renders.
236
234
  const lastUpdated = text(doc, 'p', 'dqw-meta', copy.loading)
237
235
  lastUpdated.dataset.role = 'updated'
238
236
  body.append(lastUpdated)
239
- card.append(header, body)
237
+ card.append(body)
240
238
  container.append(style, card)
239
+ // Details open in a fixed-position panel to the right of the rows.
240
+ const pop = doc.createElement('div')
241
+ pop.className = 'dqw-pop'
242
+ pop.dataset.dshQuotaWatchPop = ''
243
+ pop.hidden = true
244
+ doc.body.append(pop)
241
245
  let snapshot
242
246
  let requestSequence = 0
243
247
  let timer
@@ -247,13 +251,17 @@ export function mountQuotaCard({
247
251
 
248
252
  const fillClass = (percent) => (percent > 95 ? 'dqw-bar-fill danger' : percent > 80 ? 'dqw-bar-fill warn' : 'dqw-bar-fill')
249
253
 
250
- /** Build [row, detail] fragments for one provider, or null when it must not render. */
251
- const renderProvider = (key, model, provider) => {
254
+ /** Build the summary row for one provider, or null when it must not render. */
255
+ const renderProvider = (key, model) => {
252
256
  if (model === null) return null
253
257
  const row = doc.createElement('div')
254
258
  row.className = 'dqw-row-summary'
255
259
  row.dataset.dshQuotaWatchRow = key
256
- row.append(text(doc, 'span', 'dqw-label', model.label))
260
+ const label = text(doc, 'span', 'dqw-label', model.label)
261
+ // Clicking the provider name re-probes, standing in for the removed refresh button.
262
+ label.dataset.action = 'refresh'
263
+ label.title = `${model.label} · ${copy.refresh}`
264
+ row.append(label)
257
265
  if (model.kind === 'error') {
258
266
  row.classList.add('is-error')
259
267
  row.append(text(doc, 'span', 'dqw-errtext', model.error ? `${copy.failed}: ${model.error}` : copy.failed))
@@ -288,11 +296,7 @@ export function mountQuotaCard({
288
296
  row.append(extra)
289
297
  }
290
298
  row.append(text(doc, 'span', 'dqw-chev', openKey === key ? '▾' : '▸'))
291
- const detail = doc.createElement('div')
292
- detail.className = 'dqw-detail'
293
- detail.dataset.dshQuotaWatchDetail = key
294
- if (openKey === key) renderDetail(detail, key, provider)
295
- return [row, detail]
299
+ return [row]
296
300
  }
297
301
 
298
302
  function renderDetail(detail, key, provider) {
@@ -318,45 +322,133 @@ export function mountQuotaCard({
318
322
  )
319
323
  numbers.append(tokensBig, callsBig)
320
324
  detail.append(numbers)
321
- for (const window of provider?.plan?.windows ?? []) {
322
- const label = copy.windows[window?.key] ?? window?.key
323
- if (typeof window?.percent === 'number' && Number.isFinite(window.percent)) {
324
- detail.append(progress(doc, label, window.percent, locale))
325
- } else {
326
- detail.append(text(doc, 'p', 'dqw-muted', label))
325
+ const raw = Array.isArray(provider?.plan?.windows) ? provider.plan.windows : []
326
+ // Fixed display order (MCP, 5h, month, week); the weekly window shows an
327
+ // unlimited placeholder until the provider starts reporting one.
328
+ const order = { mcp: 0, '5h': 1, month: 2, week: 3 }
329
+ const sorted = [...raw].sort((a, b) => (order[a?.key] ?? 99) - (order[b?.key] ?? 99))
330
+ const items = []
331
+ let weekPlaced = false
332
+ for (const window of sorted) {
333
+ if (!weekPlaced && (order[window?.key] ?? 99) > order.week) {
334
+ items.push({ placeholder: true })
335
+ weekPlaced = true
327
336
  }
337
+ if (window?.key === 'week') weekPlaced = true
338
+ items.push({ window })
339
+ }
340
+ if (!weekPlaced) items.push({ placeholder: true })
341
+ // One grid keeps the name, ·, used/♾️, percent and weak-note columns aligned
342
+ // across all window rows.
343
+ const wins = doc.createElement('div')
344
+ wins.className = 'dqw-wins'
345
+ for (const item of items) {
346
+ if (item.placeholder) {
347
+ wins.append(
348
+ text(doc, 'span', 'dqw-win-name', copy.weekUnlimited),
349
+ text(doc, 'span', 'dqw-win-mid', '·'),
350
+ text(doc, 'span', 'dqw-win-used', copy.unlimited),
351
+ text(doc, 'span', ''),
352
+ text(doc, 'span', 'dqw-kv-sub', copy.unlimitedNote),
353
+ )
354
+ continue
355
+ }
356
+ const window = item.window
357
+ const percent = typeof window?.percent === 'number' && Number.isFinite(window.percent) ? window.percent : undefined
328
358
  const reset = formatTime(window?.resetsAt, locale)
329
- if (reset) detail.append(text(doc, 'p', 'dqw-muted', `${copy.reset}: ${reset}`))
359
+ wins.append(text(doc, 'span', 'dqw-win-name', copy.windows[window?.key] ?? window?.key))
360
+ wins.append(text(doc, 'span', 'dqw-win-mid', '·'))
361
+ wins.append(text(doc, 'span', 'dqw-win-used', copy.windowUsed))
362
+ wins.append(text(doc, 'span', 'dqw-win-val', percent !== undefined ? formatPercent(percent, locale) : '—'))
363
+ wins.append(text(doc, 'span', 'dqw-kv-sub', reset ? `(${copy.reset}: ${reset})` : ''))
364
+ }
365
+ detail.append(wins)
366
+ if (Array.isArray(usage?.models) && usage.models.length > 0) {
367
+ const models = doc.createElement('div')
368
+ models.className = 'dqw-models'
369
+ for (const model of usage.models) {
370
+ const row = doc.createElement('div')
371
+ row.className = 'dqw-model-row'
372
+ row.append(
373
+ text(doc, 'span', 'dqw-model-name', model?.name ?? '—'),
374
+ text(doc, 'span', 'dqw-model-value', formatCompact(model?.tokens, locale)),
375
+ )
376
+ models.append(row)
377
+ }
378
+ detail.append(models)
330
379
  }
331
- const models = Array.isArray(usage?.models) && usage.models.length > 0
332
- ? usage.models.map((model) => `${model.name} ${formatCompact(model.tokens, locale)}`).join(' · ')
333
- : undefined
334
- if (models) detail.append(text(doc, 'p', 'dqw-models', models))
335
- const updated = formatTime(provider?.updatedAt ? new Date(provider.updatedAt).toISOString() : '', locale)
336
- if (updated) detail.append(text(doc, 'p', 'dqw-meta', `${copy.updated}: ${updated}`))
337
380
  return
338
381
  }
339
382
  const quota = provider?.quota
340
383
  if (quota?.balanceAvailable === true) {
341
- detail.append(text(doc, 'p', 'dqw-credit', `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`))
384
+ detail.append(kvRow(doc, copy.available, `${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`))
342
385
  const used = remainingToUsed(quota.percentRemaining)
343
- if (used !== undefined) detail.append(progress(doc, copy.used, used, locale))
344
386
  if (typeof quota.creditsUsed === 'number' && quota.creditsUsed >= 0) {
345
- detail.append(text(doc, 'p', 'dqw-muted', `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`))
387
+ const row = doc.createElement('div')
388
+ row.className = 'dqw-kv'
389
+ const value = doc.createElement('span')
390
+ value.className = 'dqw-kv-value'
391
+ value.append(text(doc, 'span', '', formatNumber(quota.creditsUsed, locale)))
392
+ // The percent duplicates the summary bar, so render it weakened.
393
+ if (used !== undefined) value.append(text(doc, 'span', 'dqw-kv-sub', ` · ${formatPercent(used, locale)}`))
394
+ row.append(text(doc, 'span', 'dqw-kv-label', copy.used), value)
395
+ detail.append(row)
346
396
  }
397
+ const reset = formatTime(quota?.resetsAt, locale)
398
+ if (reset) detail.append(kvRow(doc, copy.resetTime, reset))
347
399
  } else {
348
400
  detail.append(text(doc, 'p', 'dqw-muted', copy.stale))
349
401
  }
350
- const reset = formatTime(quota?.resetsAt, locale)
351
- if (reset) detail.append(text(doc, 'p', 'dqw-muted', `${copy.reset}: ${reset}`))
402
+ }
403
+
404
+ /** Keep the detail panel anchored to the expanded row's right edge. */
405
+ const positionPop = () => {
406
+ if (pop.hidden || openKey === undefined) return
407
+ const row = body.querySelector(`[data-dsh-quota-watch-row="${openKey}"]`)
408
+ if (!row) return
409
+ const rect = row.getBoundingClientRect()
410
+ const viewWidth = win.innerWidth ?? 1024
411
+ const viewHeight = win.innerHeight ?? 768
412
+ const width = pop.offsetWidth || 240
413
+ const height = pop.offsetHeight || 160
414
+ let left = rect.right + 8
415
+ if (left + width > viewWidth - 8) left = rect.left - width - 8
416
+ if (left + width > viewWidth - 8) left = viewWidth - width - 8
417
+ left = Math.max(8, left)
418
+ const top = Math.min(Math.max(rect.top, 8), Math.max(8, viewHeight - height - 8))
419
+ pop.style.left = `${Math.round(left)}px`
420
+ pop.style.top = `${Math.round(top)}px`
421
+ }
422
+
423
+ const syncPop = () => {
424
+ if (openKey === undefined || disposed) {
425
+ if (!pop.hidden) {
426
+ pop.hidden = true
427
+ pop.replaceChildren()
428
+ delete pop.dataset.dshQuotaWatchDetail
429
+ }
430
+ return
431
+ }
432
+ const provider = (snapshot?.providers ?? []).find((item) => item?.key === openKey)
433
+ const row = body.querySelector(`[data-dsh-quota-watch-row="${openKey}"]`)
434
+ if (!provider || !row || !row.hasAttribute('data-expandable')) {
435
+ openKey = undefined
436
+ syncPop()
437
+ return
438
+ }
439
+ renderDetail(pop, openKey, provider)
440
+ pop.dataset.dshQuotaWatchDetail = openKey
441
+ pop.setAttribute('aria-label', openKey === 'glm' ? 'GLM' : 'Copilot')
442
+ pop.hidden = false
443
+ positionPop()
352
444
  }
353
445
 
354
446
  const renderAll = () => {
355
447
  const providers = snapshot?.providers ?? []
356
448
  const glm = providers.find((provider) => provider?.key === 'glm')
357
449
  const copilot = providers.find((provider) => provider?.key === 'copilot')
358
- const glmParts = renderProvider('glm', glmRowModel(glm), glm)
359
- const copilotParts = renderProvider('copilot', copilotRowModel(copilot), copilot)
450
+ const glmParts = renderProvider('glm', glmRowModel(glm))
451
+ const copilotParts = renderProvider('copilot', copilotRowModel(copilot))
360
452
  const fragment = doc.createDocumentFragment()
361
453
  fragment.append(lastUpdated)
362
454
  if (glmParts) fragment.append(...glmParts)
@@ -364,7 +456,13 @@ export function mountQuotaCard({
364
456
  body.replaceChildren(fragment)
365
457
  container.hidden = glmParts === null && copilotParts === null
366
458
  const updated = formatTime(snapshot?.updatedAt ? new Date(snapshot.updatedAt).toISOString() : '', locale)
367
- lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading
459
+ if (updated) {
460
+ lastUpdated.hidden = true
461
+ } else {
462
+ lastUpdated.hidden = false
463
+ lastUpdated.textContent = copy.loading
464
+ }
465
+ syncPop()
368
466
  }
369
467
 
370
468
  const render = (next) => {
@@ -373,32 +471,29 @@ export function mountQuotaCard({
373
471
  }
374
472
 
375
473
  const showTransportError = () => {
376
- if (snapshot) lastUpdated.textContent = copy.requestFailed
377
- else lastUpdated.textContent = copy.failed
474
+ lastUpdated.hidden = false
475
+ lastUpdated.textContent = snapshot ? copy.requestFailed : copy.failed
378
476
  }
379
477
 
380
478
  const poll = async (force) => {
381
479
  if (disposed) return
382
480
  const sequence = ++requestSequence
383
- refreshButton.disabled = force === true
384
481
  try {
385
482
  const next = await fetchJson(fetchImpl, force ? CLIENT_ROUTES.refresh : CLIENT_ROUTES.overview, force ? 'POST' : 'GET')
386
483
  if (sequence === requestSequence && !disposed) render(next)
387
484
  } catch {
388
485
  if (sequence === requestSequence && !disposed) showTransportError()
389
- } finally {
390
- if (!disposed && sequence === requestSequence) refreshButton.disabled = false
391
486
  }
392
487
  }
393
488
 
394
489
  const place = () => {
395
490
  const foot = footArea(doc)
396
491
  if (!foot) return
397
- const settings = foot.querySelector('[class*="settingsArea"]')
398
- if (settings) {
399
- if (container.parentElement !== foot || container.nextElementSibling !== settings) foot.insertBefore(container, settings)
400
- } else if (container.parentElement !== foot || foot.lastElementChild !== container) {
401
- foot.append(container)
492
+ // Claim the first slot of the sidebar footer so the card sits above the
493
+ // framework-rendered plugin entries and the settings row; the mutation
494
+ // observer re-seats it whenever the framework displaces it.
495
+ if (container.parentElement !== foot || foot.firstElementChild !== container) {
496
+ foot.insertBefore(container, foot.firstChild)
402
497
  }
403
498
  }
404
499
 
@@ -417,8 +512,8 @@ export function mountQuotaCard({
417
512
 
418
513
  const startPolling = () => {
419
514
  if (timer !== undefined || doc.visibilityState === 'hidden' || disposed) return
420
- void poll(false)
421
- timer = win.setInterval(() => { void poll(false) }, pollIntervalMs)
515
+ void poll()
516
+ timer = win.setInterval(() => { void poll() }, pollIntervalMs)
422
517
  }
423
518
  const stopPolling = () => {
424
519
  if (timer !== undefined) win.clearInterval(timer)
@@ -428,12 +523,15 @@ export function mountQuotaCard({
428
523
  if (doc.visibilityState === 'hidden') stopPolling()
429
524
  else startPolling()
430
525
  }
431
- const onRefresh = () => { void poll(true) }
432
526
  const toggleRow = (key) => {
433
527
  openKey = openKey === key ? undefined : key
434
528
  renderAll()
435
529
  }
436
530
  const onBodyClick = (event) => {
531
+ if (event.target?.closest?.('[data-action="refresh"]')) {
532
+ void poll(true)
533
+ return
534
+ }
437
535
  const row = event.target?.closest?.('[data-dsh-quota-watch-row][data-expandable]')
438
536
  if (row) toggleRow(row.dataset.dshQuotaWatchRow)
439
537
  }
@@ -445,10 +543,28 @@ export function mountQuotaCard({
445
543
  toggleRow(row.dataset.dshQuotaWatchRow)
446
544
  }
447
545
  }
448
- refreshButton.addEventListener('click', onRefresh)
546
+ const onDocPointerDown = (event) => {
547
+ if (openKey === undefined) return
548
+ const target = event.target
549
+ if (pop.contains(target) || target?.closest?.('[data-dsh-quota-watch-row]') || target?.closest?.(CARD_SELECTOR)) return
550
+ openKey = undefined
551
+ renderAll()
552
+ }
553
+ const onDocScroll = () => { positionPop() }
554
+ const onWinResize = () => { positionPop() }
555
+ const onDocKeydown = (event) => {
556
+ if (event.key === 'Escape' && openKey !== undefined) {
557
+ openKey = undefined
558
+ renderAll()
559
+ }
560
+ }
449
561
  body.addEventListener('click', onBodyClick)
450
562
  body.addEventListener('keydown', onBodyKeydown)
451
563
  doc.addEventListener('visibilitychange', onVisibilityChange)
564
+ doc.addEventListener('pointerdown', onDocPointerDown, true)
565
+ doc.addEventListener('scroll', onDocScroll, true)
566
+ doc.addEventListener('keydown', onDocKeydown)
567
+ win.addEventListener('resize', onWinResize)
452
568
  startPolling()
453
569
 
454
570
  return () => {
@@ -456,10 +572,14 @@ export function mountQuotaCard({
456
572
  stopPolling()
457
573
  observer.disconnect()
458
574
  doc.removeEventListener('visibilitychange', onVisibilityChange)
459
- refreshButton.removeEventListener('click', onRefresh)
575
+ doc.removeEventListener('pointerdown', onDocPointerDown, true)
576
+ doc.removeEventListener('scroll', onDocScroll, true)
577
+ doc.removeEventListener('keydown', onDocKeydown)
578
+ win.removeEventListener('resize', onWinResize)
460
579
  body.removeEventListener('click', onBodyClick)
461
580
  body.removeEventListener('keydown', onBodyKeydown)
462
581
  container.remove()
582
+ pop.remove()
463
583
  }
464
584
  }
465
585