dsh-connect-minimaxcode 0.6.6 → 0.6.8

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.en.md CHANGED
@@ -384,6 +384,47 @@ The OAuth token is valid for about an hour and is refreshed automatically by the
384
384
 
385
385
  ## Changelog
386
386
 
387
+ ### 0.6.8 (2026-11-13)
388
+
389
+
390
+ - **The plan tier moved into the Plan box**: the tier and the day it ends used to be a row of their own below the two boxes, stretched into a band of empty space. The tier is now the Plan box's headline line, wearing **the balance's own big-number size and height** (20px), and the day it ends stays the small text it was, on the right and on the tier's baseline; the box itself is named "Plan" rather than "Plan allowance".
391
+ - **The grid has no hole any more**: the tier row no longer occupies a grid cell, so the check-in and the expiring grants now sit side by side in the second row and both columns are filled — the empty band under the left column and the hole at the bottom right are gone together. The Plan box did not grow by a row either: the tier line takes the place of the invisible number that used to hold the row height open.
392
+ - The tier is still **the value the upstream sends** (`token_plan_tier`), not a fixed label: another plan shows its own name, and only an answer without that field falls back to "M Plan subscription".
393
+
394
+
395
+ - **Fixed "Could not send it to the conversation": the session method was never bound.** Before this release the composer's **Generate an image** entry failed *every* time, reporting "Could not send it to the conversation; generate on the plugin page and download it instead." The plugin took `session.prompt` off the session object and then called it as a standalone function:
396
+
397
+ ```ts
398
+ const send = binding?.session?.prompt
399
+ return typeof send === 'function' ? { prompt: send } : undefined
400
+ ```
401
+
402
+ The host's own `Session.prompt` reaches the gateway **through `this.remote`**:
403
+
404
+ ```ts
405
+ result = await this.remote.session.prompt({ ... })
406
+ ```
407
+
408
+ Once the method is taken off the object, `this` no longer points at the session but at the `{ prompt: send }` wrapper, so `this.remote` is `undefined` and the call throws `undefined is not an object (evaluating 'this.remote.session')`. The method is now bound back to its session (`beginSubmission` for the same reason), and the picture actually goes out.
409
+
410
+ Worth noting: the draft channel in the same file has always written `create.bind(conversation)` — only the send path missed it. And because the send path is tried first, the draft path never runs, so that correct binding could not rescue it.
411
+
412
+ - **A sent picture now shows up in the conversation.** The plugin used to invent its own `requestId` (`minimax-h3-${Date.now()}`) and hand it to `prompt()`: the model received the picture, but the **conversation showed no such message** — the host wants a local submission echo registered with `beginSubmission()` first, and the prompt to carry the identity that registration mints. The dock now does what the host's own composer does, and the picture arrives as part of that turn.
413
+
414
+ - **A refusal is no longer read as a success.** A refused prompt resolves with `{ ok: false }` rather than throwing, and the plugin used to treat that as success and clear the note, leaving nothing on screen. The outcome is now read, and a refusal says so.
415
+
416
+ - **Three new regression cases.** The mock in `tests/generate-dock.spec.ts` used to be an **arrow function that never reads `this`**, so whether the method was bound could not be observed — while the real host reads `this.remote`, and that is exactly the difference. The new cases make the mock read `this.remote` the way the host does, and pin three things: the call keeps the session as its receiver, the prompt carries the identity `beginSubmission` minted, and a refusal is reported. **Revert the fix and they fail at once.**
417
+
418
+
419
+ ### 0.6.7 (2026-11-12)
420
+
421
+ - **Merged PR [#5](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/5) by [azsora](https://github.com/azsora)**:
422
+ - **The remaining percent the documented quota endpoint sends is read too**. Once a plan is held, the upstream renamed the window field from the share *used* (`"19%"`) to the share *remaining* (`90`); 0.6.5 already reads both shapes. This PR closes the other gap — when the documented endpoint answers **200 but carries a shape that cannot be read**, the older path is tried too, not only when the request throws.
423
+ - **The surfaces are reworked**: the pill detail and the plugin page card are now rounded sub-cards (balance, limit windows, check-in and expiring grants each in their own), and the generation panel wears the same shell.
424
+ - **Why there are no windows is now named**: instead of one "unavailable", each state is told apart — **not signed in / the sign-in expired / no subscription / the read failed** — and a failed read shows the upstream's own words. That is the part a free account needs most.
425
+ - **"Expiring soon" now covers three days**, and the count and the wording agree (the upstream's lots expire day by day over thirty-odd days; the window is a display choice, not an upstream fact).
426
+ - Together with the window fix in 0.6.5, a subscribed account now sees both its five-hour window and its weekly window read correctly.
427
+
387
428
  ### 0.6.6 (2026-11-12)
388
429
 
389
430
  - **The README gained a "Feedback and contributions" section**: an email address (1628451685@qq.com), issues and pull requests, and a note that reports from **free accounts** are the ones most wanted — a subscribed account can read the limit windows and the plan tier, so some problems only show up on a free account and I may not be able to reproduce them here. It also lists what is worth including in a report, and invites a PR for anyone who fixes it themselves.
@@ -633,6 +674,9 @@ Fixing it yourself is even better — just open the PR. The format does not matt
633
674
  - **[eghrhegpe](https://github.com/eghrhegpe)** — raised the request for a plugin-page icon and Chinese/English copy ([#1](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/issues/1)). It became 0.5.2: the plugin page has its own SVG artwork, the title and the introduction come in both languages, and the interface follows the host's language.
634
675
  - **[azsora](https://github.com/azsora)** — found the plan's rate-limit endpoint in the desktop app's own traffic and wired the 5-hour and weekly usage windows into the credit pill and the plugin page ([#4](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/4)): the window length is derived from the response's own edges rather than hard-coded. It became 0.5.5.
635
676
  - **[qzz19970504](https://github.com/qzz19970504)** — found and fixed the wiring gap that made every image upload fail ([#3](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/3)): the catalog declared the `image` modality, but the adapter never connected the host's durable attachment service, so pi-ai refused image requests before they were sent. The fix became 0.5.3.
677
+ - **[azsora](https://github.com/azsora)** — found that the official quota endpoint renamed its window field and inverted its meaning (the share used became the share remaining), closed the gap where a 200 with an unreadable shape never reached the older path, and reworked the pill detail and the page card into rounded sub-cards that say why there are no windows ([#5](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/5)). It became 0.6.7.
678
+ - **[lulunick](https://github.com/lulunick)** — found the real reason the "Generate an image" entry failed **every** time: the plugin took `session.prompt` off the session object and called it as a standalone function, so `this` pointed at the wrapper instead of at the session — and the host's own `Session.prompt` reaches the gateway through `this.remote`, which made the call throw `undefined is not an object`. With the methods bound back to their session the picture actually goes out; he also added the local submission echo the host asks for (`beginSubmission()` — the plugin used to invent its own `requestId`, so the model received the picture and the conversation showed nothing) and reports a refusal honestly (the host resolves with `{ ok: false }` rather than throwing). The test's mock now reads `this.remote` the way the host does, which is what makes all three observable ([#7](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/7)). It became 0.6.8.
679
+ - **[azsora](https://github.com/azsora)** — moved the plan tier into the Plan box as its headline line, at the balance box's own big-number size, named the box "Plan", and left no hole in the grid ([#6](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/6)). It became 0.6.8.
636
680
  - Plugin author: **[JeffTsuiCUG](https://github.com/JeffTsuiCUG)**
637
681
 
638
682
  ## Acknowledgements
package/README.md CHANGED
@@ -395,6 +395,47 @@ OAuth 令牌有效期约 1 小时,由客户端自动续期。插件**只在自
395
395
 
396
396
  ## 更新记录
397
397
 
398
+ ### 0.6.8(2026-11-13)
399
+
400
+
401
+ - **套餐档位移进 Plan 框**:档位与到期日原本是卡片网格里独立的一行,横在两个框下面,被拉伸成一片空白。现在档位成为 Plan 框的标题行,**字号与高度和余额框的大数字完全一致**(20px),到期日仍是原来的小字、靠右基线对齐;Plan 框的名字由「Plan 额度」简化为「Plan」。
402
+ - **网格不再留洞**:档位行不再占一个网格单元,「每日签到」与「到期批次」因此并排落到第二行,两列都填满 —— 之前左列下方那条空白与右下角的空格子一并消失。Plan 框内的高度也一行不多:档位行顶替的正是原先用来对齐行高的那个隐藏占位。
403
+ - 档位本身仍是**上游给的值**(`token_plan_tier`),不是写死的标签:别的套餐会显示它自己的名字;只有上游没返回该字段时,才回落到「M Plan 订阅」。
404
+
405
+
406
+ - **修复「无法发送到对话」:会话方法没有绑定 `this`**。这一版之前,输入框里的「生成图片」**必然**失败,提示「无法发送到对话,请到插件页生成后下载」。原因在插件把 `session.prompt` 从会话对象上摘下来之后,以独立函数的方式调用:
407
+
408
+ ```ts
409
+ const send = binding?.session?.prompt
410
+ return typeof send === 'function' ? { prompt: send } : undefined
411
+ ```
412
+
413
+ 宿主自己的 `Session.prompt` 是**通过 `this.remote` 走到网关**的:
414
+
415
+ ```ts
416
+ result = await this.remote.session.prompt({ ... })
417
+ ```
418
+
419
+ 方法一旦被摘下来,`this` 就不再指向会话,而是指向外面那个 `{ prompt: send }` 包装对象,于是 `this.remote` 是 `undefined`,调用抛出 `undefined is not an object (evaluating 'this.remote.session')`。现在方法被绑回会话对象(`beginSubmission` 同理),图片就能真正发出去了。
420
+
421
+ 顺带一提:同一个文件里的草稿通道**一直**写着 `create.bind(conversation)`,只是发送通道漏了 —— 而发送通道优先,草稿通道根本走不到,所以那个正确的绑定救不了场。
422
+
423
+ - **发图现在会出现在对话里**。此前插件自己编了一个 `requestId`(`minimax-h3-${Date.now()}`)交给 `prompt()`:模型能收到图片,但**对话里不显示这条消息** —— 宿主要求先用 `beginSubmission()` 注册一次本地回执、再用它给出的身份发送。现在照宿主输入框的做法走,图片作为这一轮消息的一部分出现。
424
+
425
+ - **不再把失败当成功**。`prompt()` 被拒绝时返回的是 `{ ok: false }` 而不是抛异常,插件此前会把它当成功、把提示条清空,于是屏幕上什么都不剩。现在会读出结果,拒绝时如实说「无法发送到对话」。
426
+
427
+ - **新增回归测试(3 个用例)**。此前 `tests/generate-dock.spec.ts` 的 mock 是个**不读 `this`** 的箭头函数,因此方法有没有绑定根本测不出来 —— 真实宿主要读 `this.remote`,差别正在于此。这次的新用例让 mock 像宿主一样读 `this.remote`,三条断言分别钉住:调用带着会话当接收者、发送的是 `beginSubmission` 给出的身份、宿主拒绝时如实提示。**把这三条用例里的修复撤掉,它们会立刻失败。**
428
+
429
+
430
+ ### 0.6.7(2026-11-12)
431
+
432
+ - **并入 PR [#5](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/5)(作者 [azsora](https://github.com/azsora))**:
433
+ - **官方额度接口的「剩余百分比」也能读**。上游在有套餐后把窗口字段从「已用百分比」(`"19%"`)改成了「**剩余**百分比」(`90`),插件在 0.6.5 已能读这两种形态;本 PR 补上了另一处缺口 —— 官方接口回 **200 但形状读不懂**时也要回落旧路径,而不只是抛异常时才回落。
434
+ - **界面重排**:积分胶囊的详情与插件页卡片改为圆角分栏(余额、额度窗口、签到、到期批次各成一栏),生成面板沿用同样的外框。
435
+ - **没有窗口时说明原因**:不再只显示「不可用」,而是逐项区分**未登录 / 登录态已过期 / 未订阅 / 读取失败**,失败时把上游原话显示出来 —— 对免费账户最有用。
436
+ - **即将过期改为 3 天口径**:计数与文案一致(上游的批次按天到期、铺开三十多天,这个窗口是显示口径而非上游事实)。
437
+ - 本次合并与 0.6.5 的窗口修复一并生效:订阅账户的 5 小时窗口与周窗口现在都能正确显示。
438
+
398
439
  ### 0.6.6(2026-11-12)
399
440
 
400
441
  - **README 新增「反馈与参与」**:留下邮箱(1628451685@qq.com)、issue 与 PR 三种联系方式,并说明**特别希望听到免费账户的反馈** —— 订阅账户能读到限额窗口与套餐档位,有些问题只有免费账户才会遇到、而我在本地未必能复现。附上了报问题时值得带上的几样信息,以及「自己修好直接提 PR」的邀请。
@@ -646,6 +687,9 @@ dsh plugin --profile desktop add dsh-qoder-connect
646
687
  - **[eghrhegpe](https://github.com/eghrhegpe)** —— 提出了插件页图标与中英文文案的建议([#1](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/issues/1))。这条建议促成了 0.5.2:插件页有了自己的 SVG 图标,标题与简介有了中英两版,界面文案也开始跟随宿主语言。
647
688
  - **[azsora](https://github.com/azsora)** —— 从桌面 App 的实际调用里找出了套餐的**限额窗口接口**,并把 5 小时与每周两个窗口的用量接进了积分胶囊与插件页卡片([#4](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/4)):窗口时长从响应自身的起止字段推导,不写死。该功能成为 0.5.5。
648
689
  - **[qzz19970504](https://github.com/qzz19970504)** —— 定位并修复了「发图必然失败」的接线缺口([#3](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/3)):模型目录声明了 `image` 模态,但适配器没有把宿主的持久化附件服务接上,导致带图请求被 pi-ai 在发出前拒掉。该修复成为 0.5.3。
690
+ - **[azsora](https://github.com/azsora)** —— 找出官方额度接口改名并反转语义(「已用百分比」→「剩余百分比」)这件事,补上了「200 但形状读不懂时也要回落旧路径」的缺口,并把积分胶囊与插件页卡片重排为圆角分栏、让「为什么没有窗口」逐项有说法([#5](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/5))。该合并成为 0.6.7。
691
+ - **[lulunick](https://github.com/lulunick)** —— 找到了「生成图片**必然**失败」的真正原因:插件把 `session.prompt` 从会话对象上摘下来后以独立函数调用,`this` 因此指向了包装对象而不是会话,宿主自己的 `Session.prompt` 正是通过 `this.remote` 走到网关的,于是抛 `undefined is not an object`。方法被绑回会话后图片才真正发得出去;顺带补上了宿主要求的 `beginSubmission()` 本地回执(此前插件自己编 `requestId`,模型收到了图片但对话里不显示),以及被拒绝时(返回 `{ ok: false }` 而非抛异常)的如实提示。他同时把测试里的 mock 改成像宿主一样读 `this.remote`,让这三点钉得住([#7](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/7))。该修复成为 0.6.8。
692
+ - **[azsora](https://github.com/azsora)** —— 把套餐档位移进 Plan 框当标题行,字号与余额框的大数字一致,Plan 框改名为「Plan」,网格也不再留洞([#6](https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/pull/6))。该重排成为 0.6.8。
649
693
  - 插件作者:**[JeffTsuiCUG](https://github.com/JeffTsuiCUG)**
650
694
 
651
695
  ## 致谢
package/lib/client.js CHANGED
@@ -30,9 +30,9 @@
30
30
  */
31
31
  const MINIMAX_PROVIDER_ID = 'minimaxcode';
32
32
  const zh = {
33
- title: 'MiniMax Code 积分',
33
+ title: 'MiniMax Code',
34
34
  balance: '积分 {total}',
35
- free: '赠送积分',
35
+ free: '签到积分',
36
36
  purchased: '购买积分',
37
37
  consumed: '上次读取后消耗',
38
38
  tokens: '本机累计 {count} tokens',
@@ -44,9 +44,15 @@
44
44
  signedOut: '未登录 MiniMax Code',
45
45
  expiring: '即将过期',
46
46
  expiringIn: '{amount} 积分 · {date} 到期',
47
- expiringNone: '7 天内没有积分到期',
47
+ expiringNone: '3 天内没有积分到期',
48
48
  expiringSoon: '30 天内到期 {amount} 积分',
49
+ expiring3d: '3天内过期',
50
+ expiringAmount: '{amount} 积分',
49
51
  nextExpiry: '最近到期日',
52
+ creditsBox: '积分',
53
+ planTitle: 'Plan',
54
+ usageTitle: '本机用量',
55
+ usageTotal: '累计',
50
56
  lots: '到期批次',
51
57
  noLots: '没有待到期的积分批次',
52
58
  loginTitle: '登录',
@@ -110,9 +116,9 @@
110
116
  generateNoInput: '输入框不接受附件。',
111
117
  };
112
118
  const en = {
113
- title: 'MiniMax Code credits',
119
+ title: 'MiniMax Code',
114
120
  balance: '{total} credits',
115
- free: 'Free credits',
121
+ free: 'Check-in credits',
116
122
  purchased: 'Purchased credits',
117
123
  consumed: 'Spent since last read',
118
124
  tokens: '{count} tokens used here',
@@ -124,9 +130,15 @@
124
130
  signedOut: 'MiniMax Code not signed in',
125
131
  expiring: 'Expiring soon',
126
132
  expiringIn: '{amount} credits · expires {date}',
127
- expiringNone: 'No credits expire within 7 days',
133
+ expiringNone: 'No credits expire within 3 days',
128
134
  expiringSoon: '{amount} credits expire within 30 days',
135
+ expiring3d: 'Expires in 3 days',
136
+ expiringAmount: '{amount} credits',
129
137
  nextExpiry: 'Next expiry',
138
+ creditsBox: 'Credits',
139
+ planTitle: 'Plan',
140
+ usageTitle: 'Local usage',
141
+ usageTotal: 'Total',
130
142
  lots: 'Expiring grants',
131
143
  noLots: 'No grant is waiting to expire',
132
144
  loginTitle: 'Sign-in',
@@ -285,17 +297,22 @@
285
297
  function row(label, value) {
286
298
  return h('div', { style: rowStyle }, h('span', null, label), h('span', null, value));
287
299
  }
288
- /** The expiry block, shared by the pill's detail and the page card. */
289
- function expiryBlock(t, credits_) {
300
+ /**
301
+ * The expiry rows, shared by the pill's detail and the page card.
302
+ *
303
+ * The label names the window the count actually covers, and the value
304
+ * carries only the amount: when the lot falls due is the next row's
305
+ * business, not a suffix on this one.
306
+ */
307
+ function expiryRows(t, credits_) {
290
308
  if (credits_ === undefined)
291
- return null;
309
+ return [];
292
310
  const expiring = credits_.expiring ?? 0;
293
311
  const next = credits_.nextExpiryAtMs;
294
- return h('div', { style: expiryBoxStyle, 'data-minimax-credits-expiry': true }, h('div', { style: rowStyle }, h('span', null, t('expiring')), h('span', { style: expiring > 0 ? warnStyle : undefined }, expiring > 0
295
- ? t('expiringIn', { amount: credits(expiring, 2), date: day(next) })
296
- : t('expiringNone'))), credits_.expiringSoon !== undefined && credits_.expiringSoon > 0
297
- ? row(t('expiringSoon', { amount: credits(credits_.expiringSoon, 2) }), '')
298
- : null, next !== undefined ? row(t('nextExpiry'), day(next)) : null);
312
+ return [
313
+ h('div', { style: rowStyle }, h('span', null, t('expiring3d')), h('span', { style: expiring > 0 ? warnStyle : undefined }, expiring > 0 ? t('expiringAmount', { amount: credits(expiring, 2) }) : t('expiringNone'))),
314
+ next !== undefined ? row(t('nextExpiry'), day(next)) : null,
315
+ ];
299
316
  }
300
317
  /**
301
318
  * A countdown to a window reset, coarse on purpose: minutes below an
@@ -337,42 +354,44 @@
337
354
  * rather than written into the label: the plan's terms have changed
338
355
  * before, and the display should follow whatever the server says.
339
356
  */
340
- function rateLimitBlock(t, rateLimit) {
357
+ function rateLimitRows(t, rateLimit) {
341
358
  if (rateLimit === undefined || rateLimit.state !== 'ok')
342
- return null;
359
+ return [];
343
360
  if (rateLimit.interval === undefined || rateLimit.weekly === undefined)
344
- return null;
345
- return h('div', { style: expiryBoxStyle, 'data-minimax-rate-limit': true }, row(t('usageWindow', { duration: windowLabel(rateLimit.interval) }), `${rateLimit.interval.usedPercent}%(${t('resetIn', { duration: countdown(rateLimit.interval.resetAtMs) })})`), row(t('usageWindow', { duration: windowLabel(rateLimit.weekly) }), `${rateLimit.weekly.usedPercent}%(${t('resetIn', { duration: countdown(rateLimit.weekly.resetAtMs) })})`));
361
+ return [];
362
+ return [
363
+ row(t('usageWindow', { duration: windowLabel(rateLimit.interval) }), `${rateLimit.interval.usedPercent}%(${t('resetIn', { duration: countdown(rateLimit.interval.resetAtMs) })})`),
364
+ row(t('usageWindow', { duration: windowLabel(rateLimit.weekly) }), `${rateLimit.weekly.usedPercent}%(${t('resetIn', { duration: countdown(rateLimit.weekly.resetAtMs) })})`),
365
+ ];
346
366
  }
347
367
  /**
348
- * The account's own usage history.
368
+ * The account's own usage history, as label lines.
349
369
  *
350
370
  * This is not the token count of this host — the pill reports that
351
371
  * separately — but the account's days, consumption and standing, which
352
- * is what the upstream knows and nobody else does.
372
+ * is what the upstream knows and nobody else does. It belongs to the
373
+ * page card only, laid out in two columns.
353
374
  */
354
- function usageProfileBlock(t, profile) {
375
+ function usageProfileRows(t, profile) {
355
376
  if (profile === undefined || profile.state !== 'ok')
356
- return null;
357
- const rows = [];
377
+ return [];
378
+ const lines = [];
358
379
  if (profile.consecutiveDays !== undefined && profile.consecutiveDays > 0) {
359
- rows.push(row(t('usageStreak', { days: profile.consecutiveDays }), ''));
380
+ lines.push(t('usageStreak', { days: profile.consecutiveDays }));
360
381
  }
361
382
  if (profile.activeDays !== undefined) {
362
- rows.push(row(t('usageActiveDays', { active: profile.activeDays, total: profile.totalDays ?? '' }), ''));
383
+ lines.push(t('usageActiveDays', { active: profile.activeDays, total: profile.totalDays ?? '' }));
363
384
  }
364
385
  if (profile.totalTokenConsumed !== undefined) {
365
- rows.push(row(t('usageTotalTokens', { count: profile.totalTokenConsumed }), ''));
386
+ lines.push(t('usageTotalTokens', { count: profile.totalTokenConsumed }));
366
387
  }
367
388
  if (profile.rankingPercent !== undefined) {
368
- rows.push(row(t('usageRanking', { percent: profile.rankingPercent.toFixed(1) }), ''));
389
+ lines.push(t('usageRanking', { percent: profile.rankingPercent.toFixed(1) }));
369
390
  }
370
391
  if (profile.mostActiveDay?.date !== undefined) {
371
- rows.push(row(t('usageBestDay', { date: profile.mostActiveDay.date }), ''));
392
+ lines.push(t('usageBestDay', { date: profile.mostActiveDay.date }));
372
393
  }
373
- if (rows.length === 0)
374
- return null;
375
- return h('div', { style: expiryBoxStyle, 'data-minimax-usage-profile': true }, ...rows);
394
+ return lines;
376
395
  }
377
396
  /**
378
397
  * Why there are no usage windows, when there are none because the plan
@@ -387,6 +406,33 @@
387
406
  return null;
388
407
  return h('div', { style: mutedStyle, 'data-minimax-plan-notice': true }, t('planNoSubscription'));
389
408
  }
409
+ /**
410
+ * What the page card's plan box says when it has no windows to show.
411
+ *
412
+ * An empty box would read as "nothing loaded yet" and invite a refresh
413
+ * that cannot help. Every state has a reason worth naming: no sign-in,
414
+ * a stale one, a plan without windows, or a read that failed — the
415
+ * upstream's own message is shown as it arrived, because it is the
416
+ * most specific answer available.
417
+ */
418
+ function rateLimitFallback(t, rateLimit) {
419
+ if (rateLimit === undefined) {
420
+ return h('div', { style: mutedStyle }, t('unavailable'));
421
+ }
422
+ if (rateLimit.reason === 'no-subscription') {
423
+ return h('div', { style: mutedStyle, 'data-minimax-plan-notice': true }, t('planNoSubscription'));
424
+ }
425
+ if (rateLimit.state === 'signed-out') {
426
+ return h('div', { style: mutedStyle }, t('signedOut'));
427
+ }
428
+ if (rateLimit.state === 'expired') {
429
+ return h('div', { style: mutedStyle }, t('expired'));
430
+ }
431
+ if (rateLimit.error !== undefined && rateLimit.error.length > 0) {
432
+ return h('div', { style: mutedStyle }, rateLimit.error);
433
+ }
434
+ return h('div', { style: mutedStyle }, t('unavailable'));
435
+ }
390
436
  /**
391
437
  * The plan the account holds, when it holds one.
392
438
  *
@@ -394,6 +440,11 @@
394
440
  * it nothing: the rolling windows it does not have are already
395
441
  * explained by the notice beside them. What this adds is for the
396
442
  * accounts that do hold one — the tier's name and the day it ends.
443
+ *
444
+ * It heads the plan box rather than standing on its own below it: the
445
+ * tier is the plan's headline fact, so it wears the balance's own big
446
+ * number size. The day it ends is a detail beside the tier, and keeps
447
+ * the row size.
397
448
  */
398
449
  function planLine(t, plan) {
399
450
  if (plan === undefined || !plan.active)
@@ -402,12 +453,19 @@
402
453
  const expiry = plan.expiresAtMs === undefined
403
454
  ? null
404
455
  : t('planExpires', { date: day(plan.expiresAtMs) });
405
- return h('div', { style: rowStyle, 'data-minimax-plan': true }, h('span', null, tier), h('span', null, expiry ?? ''));
456
+ return h('div', { style: planHeadStyle, 'data-minimax-plan': true }, h('span', null, tier), h('span', { style: planExpiryStyle }, expiry ?? ''));
406
457
  }
407
458
  /** The pill itself; the gate decides whether it is drawn at all. */
408
459
  function CreditPill(props) {
409
460
  const [status] = useStatus();
410
461
  const [open, setOpen] = React.useState(false);
462
+ // Two ways to open, and they must not fight each other: hovering the
463
+ // pill shows the detail, and a click pins it so it stays after the
464
+ // cursor leaves. A pinned detail closes on the next click — even under
465
+ // the cursor, which is why the click also suppresses the hover that is
466
+ // still in progress until the cursor actually leaves.
467
+ const pinned = React.useRef(false);
468
+ const skipHover = React.useRef(false);
411
469
  const t = props.t ?? ((key, params) => {
412
470
  const template = zh[key] ?? key;
413
471
  return template.replace(/\{(\w+)\}/g, (_match, name) => String(params?.[name] ?? ''));
@@ -434,19 +492,39 @@
434
492
  ? balance
435
493
  : `${windowLabel(windows.interval)} ${windows.interval.usedPercent}%(${t('resetIn', { duration: countdown(windows.interval.resetAtMs) })})`;
436
494
  const detail = available
437
- ? h('div', { style: detailStyle, 'data-minimax-credits-detail': true }, row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), credits_.consumed !== undefined && credits_.consumed > 0
495
+ ? h('div', { style: detailStyle, 'data-minimax-credits-detail': true }, h('div', { style: detailFlowStyle }, h('div', { style: boxStyle }, row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), credits_.consumed !== undefined && credits_.consumed > 0
438
496
  ? row(t('consumed'), credits(credits_.consumed, 3))
439
- : null, expiryBlock(t, credits_), rateLimitBlock(t, status?.rateLimit), planNotice(t, status?.rateLimit), planLine(t, status?.plan), usageProfileBlock(t, status?.usageProfile), usage !== undefined
440
- ? h('div', { style: rowStyle }, h('span', null, t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) })), h('span', null, t('cost', { amount: money(usage.estimatedCny) })))
441
- : null, credits_.updatedAtMs !== undefined
442
- ? row(t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }), '')
443
- : null)
497
+ : null), h('div', { style: boxStyle, 'data-minimax-credits-expiry': true }, ...expiryRows(t, credits_)), h('div', { style: boxStyle, 'data-minimax-rate-limit': true }, ...rateLimitRows(t, status?.rateLimit)), usage !== undefined
498
+ ? h('div', { style: boxStyle }, row(t('usageTotal'), `${tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens)} tokens(≈¥${money(usage.estimatedCny)})`))
499
+ : null, planNotice(t, status?.rateLimit), credits_.updatedAtMs !== undefined
500
+ ? h('div', { style: mutedStyle }, t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }))
501
+ : null))
444
502
  : null;
445
503
  return h('div', { style: wrapStyle, 'data-minimax-credits': true }, h('button', {
446
504
  type: 'button',
447
505
  style: buttonStyle,
448
506
  title: t('title'),
449
- onClick: () => setOpen(!open),
507
+ onMouseEnter: () => {
508
+ if (pinned.current || skipHover.current)
509
+ return;
510
+ setOpen(true);
511
+ },
512
+ onMouseLeave: () => {
513
+ skipHover.current = false;
514
+ if (!pinned.current)
515
+ setOpen(false);
516
+ },
517
+ onClick: () => {
518
+ if (pinned.current) {
519
+ pinned.current = false;
520
+ skipHover.current = open;
521
+ setOpen(false);
522
+ }
523
+ else {
524
+ pinned.current = true;
525
+ setOpen(true);
526
+ }
527
+ },
450
528
  }, label), open ? detail : null);
451
529
  }
452
530
  /**
@@ -539,14 +617,32 @@
539
617
  * `binding(id).session.prompt(content, mode, signal, requestId)` is
540
618
  * what the composer calls when the person presses send, so a picture
541
619
  * sent this way is a normal turn.
620
+ *
621
+ * The method is taken off the session object, so it has to be bound to
622
+ * that object before it is called. `prompt` reaches the gateway
623
+ * through `this.remote`, and an unbound call runs with `this` pointing
624
+ * at the wrapper below instead of at the session — which throws
625
+ * `undefined is not an object (evaluating 'this.remote.session')` and
626
+ * leaves the dock reporting that it cannot send the picture.
542
627
  */
543
628
  const sessionFace = () => {
544
629
  const id = sessionId();
545
630
  if (id === undefined)
546
631
  return undefined;
547
632
  const binding = clientContext?.sessions?.binding?.(id);
548
- const send = binding?.session?.prompt;
549
- return typeof send === 'function' ? { prompt: send } : undefined;
633
+ const session = binding?.session;
634
+ const send = session?.prompt;
635
+ if (typeof send !== 'function')
636
+ return undefined;
637
+ // `beginSubmission` is taken off the session for the same reason: it
638
+ // is a method of the session and must keep its receiver.
639
+ const begin = session?.beginSubmission;
640
+ return {
641
+ prompt: send.bind(session),
642
+ ...(typeof begin === 'function'
643
+ ? { beginSubmission: begin.bind(session) }
644
+ : {}),
645
+ };
550
646
  };
551
647
  /**
552
648
  * The conversation's own draft channel.
@@ -619,7 +715,24 @@
619
715
  : t('generateNoBinding'));
620
716
  }
621
717
  if (face !== undefined) {
622
- await face.prompt([part, { type: 'text', text }], 'queue', undefined, `minimax-h3-${Date.now()}`);
718
+ // The host registers a local submission echo before it prompts,
719
+ // and the identity that registration mints is the one the prompt
720
+ // must carry — that is how the sent picture shows up as this
721
+ // turn's message. A made-up identity reaches the model but leaves
722
+ // no echo, so the conversation would not show what was sent.
723
+ const submission = face.beginSubmission?.({
724
+ mode: 'queue',
725
+ text,
726
+ attachments: [],
727
+ });
728
+ const result = await face.prompt([part, { type: 'text', text }], 'queue', undefined, submission?.requestId);
729
+ // A refused prompt resolves with `{ ok: false }` rather than
730
+ // throwing, so the outcome has to be read: either way the picture
731
+ // did not arrive.
732
+ if (result === undefined || result.ok !== true) {
733
+ setNote(t('generateSendFailed'));
734
+ return;
735
+ }
623
736
  setPrompt('');
624
737
  setOpen(false);
625
738
  // The picture's own arrival is the feedback: it is in the
@@ -712,10 +825,10 @@
712
825
  if (login === undefined)
713
826
  return null;
714
827
  if (login.state === 'signed-in') {
715
- return h('div', { style: sectionBoxStyle, 'data-minimax-login': true }, row(t('loginTitle'), login.source === 'plugin' ? t('loginPlugin') : t('loginDesktop')));
828
+ return h('div', { style: boxStyle, 'data-minimax-login': true }, row(t('loginTitle'), login.source === 'plugin' ? t('loginPlugin') : t('loginDesktop')));
716
829
  }
717
830
  if (login.state === 'pending') {
718
- return h('div', { style: sectionBoxStyle, 'data-minimax-login': true }, row(t('loginTitle'), t('loginPending')), login.userCode !== undefined && login.userCode.length > 0
831
+ return h('div', { style: boxStyle, 'data-minimax-login': true }, row(t('loginTitle'), t('loginPending')), login.userCode !== undefined && login.userCode.length > 0
719
832
  ? row(t('loginCode'), login.userCode)
720
833
  : null, login.verificationUriComplete !== undefined && login.verificationUriComplete.length > 0
721
834
  ? h('a', {
@@ -726,7 +839,7 @@
726
839
  }, t('loginOpen'))
727
840
  : null, h('button', { type: 'button', style: actionButtonStyle, onClick: () => { void act('login-poll'); } }, t('loginPolled')));
728
841
  }
729
- return h('div', { style: sectionBoxStyle, 'data-minimax-login': true }, row(t('loginTitle'), t('loginSignedOut')), h('button', { type: 'button', style: actionButtonStyle, onClick: () => { void act('login-start'); } }, t('loginStart')));
842
+ return h('div', { style: boxStyle, 'data-minimax-login': true }, row(t('loginTitle'), t('loginSignedOut')), h('button', { type: 'button', style: actionButtonStyle, onClick: () => { void act('login-start'); } }, t('loginStart')));
730
843
  }
731
844
  /**
732
845
  * The check-in block.
@@ -739,9 +852,15 @@
739
852
  function signinBlock(t, signin, refresh) {
740
853
  if (signin === undefined)
741
854
  return null;
742
- return h('div', { style: sectionBoxStyle, 'data-minimax-signin': true }, h('div', { style: sectionTitleStyle }, t('signinTitle')), signin.today === undefined
855
+ // The day's status sits at the title's right end: it is a property of
856
+ // the whole block, not of the day-count row below it.
857
+ return h('div', { style: boxStyle, 'data-minimax-signin': true }, h('div', { style: rowStyle }, h('span', { style: boxTitleStyle }, t('signinTitle')), h('span', { style: mutedStyle }, signin.today === undefined
858
+ ? t('signinUnavailable')
859
+ : signin.today.claimed
860
+ ? t('signinClaimed')
861
+ : signin.today.claimable ? t('signinClaimable') : t('signinUnavailable'))), signin.today === undefined
743
862
  ? h('div', { style: mutedStyle }, t('signinUnavailable'))
744
- : h('div', { style: rowStyle }, h('span', null, t('signinToday', { day: signin.today.dayNo, points: signin.today.points })), h('span', null, signin.today.claimed ? t('signinClaimed') : signin.today.claimable ? t('signinClaimable') : t('signinUnavailable'))), (signin.days ?? []).length === 0
863
+ : h('div', { style: rowStyle }, h('span', null, t('signinToday', { day: signin.today.dayNo, points: signin.today.points }))), (signin.days ?? []).length === 0
745
864
  ? null
746
865
  : h('div', { style: dayRowStyle }, ...(signin.days ?? []).map(day_ => h('span', {
747
866
  key: `day-${day_.dayNo}`,
@@ -882,7 +1001,7 @@
882
1001
  style: selectStyle,
883
1002
  onChange: (event) => onChange(event.target.value),
884
1003
  }, ...values.map(option => h('option', { key: option, value: option }, option)));
885
- return h('div', { style: sectionBoxStyle, 'data-minimax-generation': true }, h('div', { style: sectionTitleStyle }, t('generateTitle')), h('div', { style: rowStyle }, h('button', {
1004
+ return h('div', { style: genCardStyle, 'data-minimax-generation': true }, h('div', { style: cardTitleStyle }, t('generateTitle')), h('div', { style: rowStyle }, h('button', {
886
1005
  type: 'button',
887
1006
  style: mode === 'image' ? tabActiveStyle : actionButtonStyle,
888
1007
  onClick: () => { setMode('image'); setPicture(undefined); setVideo(undefined); },
@@ -934,13 +1053,50 @@
934
1053
  // usable even when the balance cannot be read.
935
1054
  h(GenerationPanel, props));
936
1055
  }
937
- return h('div', { style: pageStyle, 'data-minimax-page': true }, h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardTitleStyle }, t('title')), h('div', { style: bigNumberStyle }, t('balance', { total: credits(credits_.total, 2) })), row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), expiryBlock(t, credits_), rateLimitBlock(t, status?.rateLimit), planNotice(t, status?.rateLimit), planLine(t, status?.plan), signinBlock(t, signin, refresh), h('div', { style: sectionTitleStyle }, t('lots')), (credits_.upcoming ?? []).length === 0
938
- ? h('div', { style: mutedStyle }, t('noLots'))
939
- : h('ul', { style: listStyle }, ...(credits_.upcoming ?? []).map((lot, index) => h('li', { key: `${lot.expireAtMs}-${index}`, style: rowStyle }, h('span', null, day(lot.expireAtMs)), h('span', null, credits(lot.amount, 2))))), usage !== undefined
940
- ? h('div', { style: usageBoxStyle }, row(t('tokens', { count: tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens) }), ''), row(t('cost', { amount: money(usage.estimatedCny) }), ''), usage.requests > 0 ? row(t('requests', { count: usage.requests }), usage.lastModel ?? '') : null)
941
- : null, usageProfileBlock(t, status?.usageProfile), loginBlock(t, login, refresh), credits_.updatedAtMs !== undefined
942
- ? h('div', { style: mutedStyle }, t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() }))
943
- : null), h(GenerationPanel, props));
1056
+ // The page's rows of hairline sections are gone: the card now reads as
1057
+ // a two-column grid of soft sub-cards. Row one is the balance and the
1058
+ // plan's windows, row two the check-in cycle and the next grants due,
1059
+ // row three this host's own usage across both columns, row four the
1060
+ // account's usage history across both.
1061
+ const lots = [...(credits_.upcoming ?? [])].sort((a, b) => a.expireAtMs - b.expireAtMs);
1062
+ const windows = rateLimitRows(t, status?.rateLimit);
1063
+ // The tier and the day it ends, for an account that holds one. It
1064
+ // heads the plan box rather than standing on its own below it.
1065
+ const planHead = planLine(t, status?.plan);
1066
+ const profileLines = usageProfileRows(t, status?.usageProfile);
1067
+ const updated = credits_.updatedAtMs !== undefined
1068
+ ? t('updated', { time: new Date(credits_.updatedAtMs).toLocaleTimeString() })
1069
+ : undefined;
1070
+ const loginLine = login?.state === 'signed-in'
1071
+ ? (login.source === 'plugin' ? t('loginPlugin') : t('loginDesktop'))
1072
+ : undefined;
1073
+ return h('div', { style: pageStyle, 'data-minimax-page': true }, h('div', { style: cardStyle, 'data-minimax-credits-card': true }, h('div', { style: cardHeadStyle }, h('span', { style: cardTitleStyle }, t('title')), h('span', { style: mutedStyle }, [loginLine, updated].filter(line => line !== undefined).join(' · '))), h('div', { style: gridStyle }, h('div', { style: boxStyle }, h('div', { style: boxTitleStyle }, t('creditsBox')), h('div', { style: bigNumberStyle }, credits(credits_.total, 2)), row(t('free'), credits(credits_.free, 3)), row(t('purchased'), credits(credits_.purchased, 3)), ...expiryRows(t, credits_).slice(0, 1)), h('div', { style: boxStyle, 'data-minimax-rate-limit': true }, h('div', { style: boxTitleStyle }, t('planTitle')),
1074
+ // The tier heads the box, wearing the balance's own big number
1075
+ // size. An account without a plan has no headline to show, and
1076
+ // the invisible number keeps the windows lined up with the
1077
+ // balance box's rows instead.
1078
+ ...(planHead !== null
1079
+ ? [planHead]
1080
+ : windows.length > 0
1081
+ ? [h('div', { style: { ...bigNumberStyle, visibility: 'hidden' }, 'aria-hidden': 'true' }, '—')]
1082
+ : []), ...(windows.length > 0
1083
+ ? windows
1084
+ : [rateLimitFallback(t, status?.rateLimit)])), signinBlock(t, signin, refresh), h('div', { style: boxStyle }, h('div', { style: boxTitleStyle }, t('lots')),
1085
+ // The upstream already caps the list at the five soonest lots
1086
+ // with credits left; the sort only keeps that promise honest.
1087
+ ...(lots.length === 0
1088
+ ? [h('div', { style: mutedStyle }, t('noLots'))]
1089
+ : lots.map((lot, index) => h('div', { key: `${lot.expireAtMs}-${index}`, style: rowStyle }, h('span', null, day(lot.expireAtMs)), h('span', null, credits(lot.amount, 2)))))), usage !== undefined
1090
+ ? h('div', { style: { ...boxStyle, ...span2Style }, 'data-minimax-usage': true }, h('div', { style: boxTitleStyle }, t('usageTitle')), row(t('usageTotal'), `${tokens(usage.inputTokens + usage.outputTokens + usage.cacheReadTokens)} tokens(≈¥${money(usage.estimatedCny)})`), usage.requests > 0 ? row(t('requests', { count: usage.requests }), usage.lastModel ?? '') : null)
1091
+ : null, profileLines.length > 0
1092
+ ? h('div', { style: { ...boxStyle, ...span2Style }, 'data-minimax-usage-profile': true }, h('div', { style: boxTitleStyle }, t('accountUsage')), h('div', { style: profileGridStyle }, ...profileLines.map((line, index) => h('div', { key: `profile-${index}` }, line))))
1093
+ : null, login !== undefined && login.state !== 'signed-in'
1094
+ ? h('div', { style: span2Style }, loginBlock(t, login, refresh))
1095
+ : null)),
1096
+ // The generation panel does not need the balance: it spends the
1097
+ // account's generation allowance, not its credits, so it stays
1098
+ // usable even when the balance cannot be read.
1099
+ h(GenerationPanel, props));
944
1100
  }
945
1101
  const wrapStyle = {
946
1102
  position: 'relative',
@@ -980,13 +1136,13 @@
980
1136
  bottom: 'calc(100% + 6px)',
981
1137
  left: 0,
982
1138
  zIndex: 10,
983
- minWidth: '240px',
984
- borderRadius: '8px',
1139
+ minWidth: '260px',
1140
+ borderRadius: '14px',
985
1141
  background: 'var(--dsh-panel-background, Canvas)',
986
1142
  color: 'var(--dsh-panel-foreground, CanvasText)',
987
1143
  border: '1px solid var(--dsh-panel-border, currentColor)',
988
1144
  boxShadow: '0 6px 20px rgba(0,0,0,0.18)',
989
- padding: '8px 10px',
1145
+ padding: '10px',
990
1146
  fontSize: '12px',
991
1147
  lineHeight: 1.6,
992
1148
  textAlign: 'left',
@@ -996,19 +1152,51 @@
996
1152
  justifyContent: 'space-between',
997
1153
  gap: '16px',
998
1154
  };
999
- const expiryBoxStyle = {
1000
- marginTop: '6px',
1001
- paddingTop: '6px',
1002
- borderTop: '1px solid var(--dsh-panel-border, currentColor)',
1003
- opacity: 0.95,
1155
+ // The rounded sub-card every section of the detail and the page card
1156
+ // lives in. The fill is a translucent gray so one value works on both
1157
+ // themes; the host's own muted token wins when it offers one.
1158
+ const boxStyle = {
1159
+ background: 'var(--dsh-muted, rgba(127, 127, 127, 0.1))',
1160
+ borderRadius: '10px',
1161
+ padding: '7px 10px',
1162
+ display: 'flex',
1163
+ flexDirection: 'column',
1164
+ gap: '4px',
1004
1165
  };
1166
+ const boxTitleStyle = { fontWeight: 600, fontSize: '12px', opacity: 0.85 };
1167
+ const detailFlowStyle = { display: 'flex', flexDirection: 'column', gap: '6px' };
1005
1168
  const warnStyle = { color: 'var(--dsh-warning, #b45309)' };
1006
1169
  const cardStyle = {
1007
1170
  display: 'flex',
1008
1171
  flexDirection: 'column',
1009
- gap: '6px',
1172
+ gap: '8px',
1010
1173
  fontSize: '13px',
1011
1174
  lineHeight: 1.6,
1175
+ background: 'var(--dsh-panel-background, Canvas)',
1176
+ border: '1px solid var(--dsh-panel-border, currentColor)',
1177
+ borderRadius: '14px',
1178
+ padding: '12px',
1179
+ };
1180
+ const cardHeadStyle = {
1181
+ display: 'flex',
1182
+ justifyContent: 'space-between',
1183
+ alignItems: 'baseline',
1184
+ gap: '8px',
1185
+ flexWrap: 'wrap',
1186
+ };
1187
+ // The page card's two-column grid: credits beside the plan, the check-in
1188
+ // beside the grants, and the wide rows spanning both columns below.
1189
+ const gridStyle = {
1190
+ display: 'grid',
1191
+ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
1192
+ gap: '8px',
1193
+ };
1194
+ const span2Style = { gridColumn: '1 / -1' };
1195
+ // The account usage box lays its lines out two across.
1196
+ const profileGridStyle = {
1197
+ display: 'grid',
1198
+ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
1199
+ gap: '4px 16px',
1012
1200
  };
1013
1201
  // The page stacks the balance card and the generation panel.
1014
1202
  const pageStyle = {
@@ -1016,19 +1204,34 @@
1016
1204
  flexDirection: 'column',
1017
1205
  gap: '10px',
1018
1206
  };
1207
+ // The generation panel wears the same rounded shell as the balance
1208
+ // card, so the page reads as a stack of two cards rather than a card
1209
+ // above a bare section.
1210
+ const genCardStyle = {
1211
+ display: 'flex',
1212
+ flexDirection: 'column',
1213
+ gap: '8px',
1214
+ fontSize: '13px',
1215
+ lineHeight: 1.6,
1216
+ background: 'var(--dsh-panel-background, Canvas)',
1217
+ border: '1px solid var(--dsh-panel-border, currentColor)',
1218
+ borderRadius: '14px',
1219
+ padding: '12px',
1220
+ };
1019
1221
  const cardTitleStyle = { fontWeight: 600 };
1020
1222
  const bigNumberStyle = { fontSize: '20px', fontWeight: 600, marginBottom: '2px' };
1021
- const sectionTitleStyle = { marginTop: '8px', fontWeight: 600 };
1022
- const mutedStyle = { opacity: 0.7, fontSize: '12px' };
1023
- const listStyle = { listStyle: 'none', margin: 0, padding: 0 };
1024
- const sectionBoxStyle = {
1025
- marginTop: '10px',
1026
- paddingTop: '8px',
1027
- borderTop: '1px solid var(--dsh-panel-border, currentColor)',
1223
+ // The plan's headline line: the tier wears the balance's own big number
1224
+ // size, so the two boxes' headline lines are the same height, and the
1225
+ // day it ends stays at the row size beside it.
1226
+ const planHeadStyle = {
1227
+ ...bigNumberStyle,
1028
1228
  display: 'flex',
1029
- flexDirection: 'column',
1030
- gap: '6px',
1229
+ justifyContent: 'space-between',
1230
+ alignItems: 'baseline',
1231
+ gap: '16px',
1031
1232
  };
1233
+ const planExpiryStyle = { fontSize: '13px', fontWeight: 400, lineHeight: '1.6' };
1234
+ const mutedStyle = { opacity: 0.7, fontSize: '12px' };
1032
1235
  const actionButtonStyle = {
1033
1236
  appearance: 'none',
1034
1237
  background: 'transparent',
@@ -1057,12 +1260,6 @@
1057
1260
  };
1058
1261
  const dayTodayStyle = { opacity: 1, fontWeight: 600 };
1059
1262
  const dayClaimedStyle = { background: 'var(--dsh-panel-border, currentColor)', opacity: 0.9 };
1060
- const usageBoxStyle = {
1061
- marginTop: '8px',
1062
- paddingTop: '6px',
1063
- borderTop: '1px solid var(--dsh-panel-border, currentColor)',
1064
- opacity: 0.9,
1065
- };
1066
1263
  const tabActiveStyle = {
1067
1264
  appearance: 'none',
1068
1265
  padding: '2px 8px',
package/lib/index.js CHANGED
@@ -1549,8 +1549,8 @@ function parseRateLimitWindows(document) {
1549
1549
  const entry = remains.find((item) => typeof item === "object" && item !== null && item.model_name === "general") ?? remains[0];
1550
1550
  if (typeof entry !== "object" || entry === null) return void 0;
1551
1551
  const record = entry;
1552
- const interval = readWindow(record.current_interval_used_percent, record.current_interval_remaining_percent, record.start_time, record.end_time, record.current_interval_status);
1553
- const weekly = readWindow(record.current_weekly_used_percent, record.current_weekly_remaining_percent, record.weekly_start_time, record.weekly_end_time, record.current_weekly_status);
1552
+ const interval = readWindow(usedPercentFrom(record.current_interval_used_percent, record.current_interval_remaining_percent), record.start_time, record.end_time, record.current_interval_status);
1553
+ const weekly = readWindow(usedPercentFrom(record.current_weekly_used_percent, record.current_weekly_remaining_percent), record.weekly_start_time, record.weekly_end_time, record.current_weekly_status);
1554
1554
  if (interval === void 0 || weekly === void 0) return void 0;
1555
1555
  return {
1556
1556
  interval,
@@ -1564,24 +1564,24 @@ function parseRateLimitWindows(document) {
1564
1564
  */
1565
1565
  const UNLIMITED_STATUS = 3;
1566
1566
  /**
1567
- * The share of a window's allowance already used, whichever way it is reported.
1567
+ * The used share of a window, from either shape the upstream sends.
1568
1568
  *
1569
- * The endpoint named the used share `current_*_used_percent` and then
1570
- * renamed it to `current_*_remaining_percent` while inverting its meaning.
1571
- * The used share is what a person needs, so it is what this returns; the
1572
- * remaining share is subtracted from a hundred to get there.
1569
+ * The older path reports how much of the allowance went ("3%"); the documented
1570
+ * quota endpoint reports how much is left (96). Both describe the same window,
1571
+ * so a response that only carries the remaining share is turned into the used
1572
+ * one rather than treated as unreadable — the documented endpoint sends no
1573
+ * used field at all, which is what left the plan's windows off the card.
1573
1574
  */
1574
- function readUsedPercent(used, remaining) {
1575
- const usedPercent = parsePercent(used);
1576
- if (usedPercent !== void 0) return usedPercent;
1577
- const remainingPercent = parsePercent(remaining);
1578
- if (remainingPercent === void 0) return void 0;
1579
- return 100 - remainingPercent;
1575
+ function usedPercentFrom(used, remaining) {
1576
+ const direct = parsePercent(used);
1577
+ if (direct !== void 0) return direct;
1578
+ const rest = parsePercent(remaining);
1579
+ return rest === void 0 ? void 0 : 100 - rest;
1580
1580
  }
1581
1581
  /** Assemble one window from its percent and edge fields; all must be present. */
1582
- function readWindow(percent, remainingPercent, startAtMs, resetAtMs, status) {
1582
+ function readWindow(percent, startAtMs, resetAtMs, status) {
1583
1583
  if (status === UNLIMITED_STATUS) return void 0;
1584
- const usedPercent = readUsedPercent(percent, remainingPercent);
1584
+ const usedPercent = parsePercent(percent);
1585
1585
  const start = toNumber$1(startAtMs);
1586
1586
  const reset = toNumber$1(resetAtMs);
1587
1587
  if (usedPercent === void 0 || start === void 0 || reset === void 0) return void 0;
@@ -1745,7 +1745,7 @@ async function fetchTokenPlanWindows(apiKey, region, fetchImpl = fetch) {
1745
1745
  function summarizeExpiry(lots, now = Date.now()) {
1746
1746
  const alive = lots.filter((lot) => lot.remaining > 0 && lot.expireAtMs > now);
1747
1747
  const within = (days) => alive.filter((lot) => lot.expireAtMs - now <= days * 864e5);
1748
- const week = within(7);
1748
+ const week = within(3);
1749
1749
  const month = within(30);
1750
1750
  return {
1751
1751
  expiring: week.reduce((sum, lot) => sum + lot.remaining, 0),
@@ -1930,7 +1930,11 @@ var MinimaxCredits = class {
1930
1930
  const apiKey = this.#apiKey ?? await fetchTokenPlanApiKey(credential, region, fetchImpl);
1931
1931
  this.#apiKey = apiKey;
1932
1932
  if (apiKey !== void 0) try {
1933
- return await fetchTokenPlanWindows(apiKey, region, fetchImpl);
1933
+ const read = await fetchTokenPlanWindows(apiKey, region, fetchImpl);
1934
+ if (read.error === void 0 || read.absence !== void 0) return read;
1935
+ const legacy = await fetchRateLimitWindows(credential, region, fetchImpl);
1936
+ if (legacy !== void 0) return { windows: legacy };
1937
+ return read;
1934
1938
  } catch (error) {
1935
1939
  const legacy = await fetchRateLimitWindows(credential, region, fetchImpl);
1936
1940
  if (legacy !== void 0) return { windows: legacy };
package/package.json CHANGED
@@ -2,20 +2,20 @@
2
2
  "name": "dsh-connect-minimaxcode",
3
3
  "displayName": "DSH Connect MiniMax Code",
4
4
  "description": "把本机已登录的 MiniMax Code 桌面 App 模型接入 DeepSeek Harness:零配置、Anthropic Messages 兼容、动态模型目录,并显示积分余额与到期 — bring MiniMax Code desktop app models into DSH with a dynamic model catalog and a credit balance with expiry details.",
5
- "version": "0.6.6",
5
+ "version": "0.6.8",
6
6
  "author": "JeffTsuiCUG",
7
7
  "contributors": [
8
8
  {
9
9
  "name": "JeffTsuiCUG",
10
10
  "url": "https://github.com/JeffTsuiCUG"
11
11
  },
12
- {
13
- "name": "eghrhegpe",
14
- "url": "https://github.com/eghrhegpe"
12
+ {
13
+ "name": "eghrhegpe",
14
+ "url": "https://github.com/eghrhegpe"
15
15
  },
16
- {
17
- "name": "qzz19970504",
18
- "url": "https://github.com/qzz19970504"
16
+ {
17
+ "name": "qzz19970504",
18
+ "url": "https://github.com/qzz19970504"
19
19
  },
20
20
  {
21
21
  "name": "azsora",