coding-usage-bar 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/README.zh-CN.md +11 -0
- package/dist/cli.js +7 -5
- package/dist/codex.d.ts +3 -1
- package/dist/codex.js +60 -23
- package/dist/deepseek.js +4 -1
- package/dist/display-probe.d.ts +7 -0
- package/dist/display-probe.js +71 -0
- package/dist/format.js +18 -0
- package/dist/glm.js +24 -9
- package/dist/install.js +5 -1
- package/dist/menubar.d.ts +6 -2
- package/dist/menubar.js +135 -43
- package/dist/pricing.d.ts +5 -0
- package/dist/pricing.js +115 -0
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +6 -0
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,12 @@ coding-usage-bar status
|
|
|
35
35
|
|
|
36
36
|
## Troubleshooting
|
|
37
37
|
|
|
38
|
+
**Menu bar icon showing on your external display but not on the laptop?**
|
|
39
|
+
|
|
40
|
+
That is a width problem, not a bug. macOS does not clip a menu bar item that is too wide -- it pushes it left, under the notch and into the app menu area, where it is simply gone. A notched 13" leaves 645pt to the right of the notch for *every* menu bar icon on the machine, and the full six-provider title alone is 511pt.
|
|
41
|
+
|
|
42
|
+
Coding Usage Bar measures that space and picks a title width to fit (see [Menu Bar](#menu-bar)). Open the dropdown and read the `Title` row: its tooltip tells you how much space was measured and which width was chosen. Click the row to override.
|
|
43
|
+
|
|
38
44
|
**Menu bar icon not showing after install?**
|
|
39
45
|
|
|
40
46
|
1. Run `coding-usage-bar doctor --fix`. It relaunches SwiftBar, reinstalls the menu bar plugin if missing, re-adds SwiftBar to login items, and collects status data.
|
|
@@ -55,6 +61,7 @@ coding-usage-bar status
|
|
|
55
61
|
| `coding-usage-bar status --json` | Print the same status snapshot written to `~/.coding-usage-bar/status.json` |
|
|
56
62
|
| `coding-usage-bar status --refresh` | Re-collect local usage before printing status |
|
|
57
63
|
| `coding-usage-bar menubar render` | Print SwiftBar-compatible menu text from `~/.coding-usage-bar/status.json` |
|
|
64
|
+
| `coding-usage-bar menubar cycle-title-mode` | Cycle the menu bar title width: `auto` → `full` → `single` → `icon` |
|
|
58
65
|
| `coding-usage-bar menubar install` | Install the SwiftBar plugin wrapper |
|
|
59
66
|
| `coding-usage-bar menubar uninstall` | Remove the Coding Usage Bar managed SwiftBar plugin |
|
|
60
67
|
| `coding-usage-bar ingest claude-statusline` | Read Claude Code status line JSON from stdin and cache usage |
|
|
@@ -107,6 +114,8 @@ Coding Usage Bar calls the Zhipu AI quota API (`GET /api/monitor/usage/quota/lim
|
|
|
107
114
|
|
|
108
115
|
Get your API key from the [Zhipu AI console](https://open.bigmodel.cn). Without this key, GLM monitoring will report `GLM_API_KEY_MISSING`.
|
|
109
116
|
|
|
117
|
+
Zhipu meters newer Coding Plans in credits (积分) instead of raw tokens and reports them as `CREDIT_LIMIT` windows. Coding Usage Bar keys off the response shape, records `quotaUnit` as `credits` or `tokens` in `status.json`, and labels the dropdown with `metered in credits` so the 5h/7d percentages read correctly for either plan generation. This detection has not been verified against a real V3 (points-based) account yet; it is based on the `CREDIT_LIMIT` shape observed on existing plans. If you are on a V3 plan, a redacted `coding-usage-bar status --refresh` output in an issue would confirm it.
|
|
118
|
+
|
|
110
119
|
## DeepSeek
|
|
111
120
|
|
|
112
121
|
Coding Usage Bar calls `GET https://api.deepseek.com/user/balance` to read your account balance. DeepSeek exposes balance rather than 5h/7d usage windows, so the menu bar shows `Available` or `Depleted` plus the currency amount. Set `deepseek.apiKey` in `~/.coding-usage-bar/config.json`:
|
|
@@ -122,6 +131,17 @@ Coding Usage Bar calls `GET https://api.deepseek.com/user/balance` to read your
|
|
|
122
131
|
|
|
123
132
|
Without this key, DeepSeek monitoring will report `DEEPSEEK_API_KEY_MISSING`.
|
|
124
133
|
|
|
134
|
+
## Peak and off-peak pricing
|
|
135
|
+
|
|
136
|
+
GLM Coding Plan and the DeepSeek API both publish a clock-based discount, and both state it in Beijing time. Coding Usage Bar computes the current tier on the producer side, stores it in `status.json` as `pricing`, and shows it in the CLI table (`price` row), the dropdown, and the usage card, together with the time of the next flip:
|
|
137
|
+
|
|
138
|
+
| Provider | Peak (Asia/Shanghai) | Off-peak | Holidays |
|
|
139
|
+
|---|---|---|---|
|
|
140
|
+
| DeepSeek API | Mon–Fri 09:00–12:00 and 14:00–18:00 | 50% of the peak rate | Chinese public holidays are off-peak all day |
|
|
141
|
+
| GLM Coding Plan | Mon–Fri 14:00–18:00 | credits deducted at 50% | Not excluded by the published rule |
|
|
142
|
+
|
|
143
|
+
Sources: [DeepSeek pricing](https://api-docs.deepseek.com/quick_start/pricing) and the [GLM Coding Plan overview](https://docs.bigmodel.cn/cn/coding-plan/overview). The 2026 holiday table follows the State Council notice (国办发明电〔2025〕7号); adjusted working weekends stay off-peak because both vendors phrase the rule as Monday to Friday. Time-boxed promotions are not modelled. The tier only tells you what the provider charges right now; it does not change the burn analysis.
|
|
144
|
+
|
|
125
145
|
## MiniMax (M3)
|
|
126
146
|
|
|
127
147
|
Coding Usage Bar calls `GET https://api.minimaxi.com/v1/token_plan/remains` to read 5h and 7d usage windows. MiniMax returns two signals per model: a count-based ratio (`current_*_usage_count` / `current_*_total_count`) for tiered models like `video`, and a credit-based `current_*_remaining_percent` (0-100) for the `general` model where the count stays at 0. Coding Usage Bar prefers the count ratio when total > 0 and otherwise derives used% as `100 - remaining_percent`. Set `minimax.apiKey` and (optionally) `minimax.region` in `~/.coding-usage-bar/config.json`:
|
|
@@ -193,6 +213,22 @@ The compact menu bar title and dropdown use recognizable Provider marks for Code
|
|
|
193
213
|
{Codex icon} 5H:14%,7D:67% │ {Claude icon} 5H:24%,7D:74% │ {GLM icon} 5H:36%,7D:7%
|
|
194
214
|
```
|
|
195
215
|
|
|
216
|
+
### Title width
|
|
217
|
+
|
|
218
|
+
The menu bar is the scarcest space on the machine, and macOS gives no way to ask whether an item will fit. So the producer measures what is available and the title picks a width that survives it:
|
|
219
|
+
|
|
220
|
+
| Tier | Width | Shows |
|
|
221
|
+
|------|-------|-------|
|
|
222
|
+
| `full` | ~511pt | Every provider with its own mark and bars |
|
|
223
|
+
| `single` | ~68pt | Only the provider closest to trouble |
|
|
224
|
+
| `icon` | ~35pt | A state-colored flame; the full title moves to the tooltip |
|
|
225
|
+
|
|
226
|
+
`auto` is the default and picks a tier from the measured budget: a full-width menu bar gets `full`, a notched display gets `single`, and anything narrower falls to `icon`. The dropdown card is identical in every tier -- narrowing the title never costs you data, only a glance.
|
|
227
|
+
|
|
228
|
+
The measurement runs in the daemon, never in the render path, so switching displays takes up to one collection cycle (300s) to follow. Click the `Title` row in the dropdown to cycle `auto` → `full` → `single` → `icon` and pin a width immediately.
|
|
229
|
+
|
|
230
|
+
Whatever fails -- no measurement, a missing asset, a PNG error -- the title falls back to the flame icon. It never renders as nothing, because an invisible menu bar item is indistinguishable from a broken tool.
|
|
231
|
+
|
|
196
232
|
SwiftBar only supports one bitmap image on a single stable title item, so Coding Usage Bar renders the full title into one transparent PNG at render time. That bitmap keeps each provider marker next to its own usage segment while avoiding SwiftBar's multi-title rotation behavior. The dropdown stays read-only and uses SwiftBar-native symbols, badges, progress meters, reset time, target range, data age, and warnings. SwiftBar is a host dependency; `coding-usage-bar uninstall` removes the Coding Usage Bar plugin but does not uninstall SwiftBar itself.
|
|
197
233
|
|
|
198
234
|
## Runtime Files
|
|
@@ -201,7 +237,8 @@ SwiftBar only supports one bitmap image on a single stable title item, so Coding
|
|
|
201
237
|
|------|---------|
|
|
202
238
|
| `~/.coding-usage-bar/app/` | Stable runtime copy used by launchd, Claude ingest hints, and SwiftBar |
|
|
203
239
|
| `~/.coding-usage-bar/config.json` | Provider selection, default `["codex", "claude", "glm", "deepseek", "minimax", "kimi"]` |
|
|
204
|
-
| `~/.coding-usage-bar/status.json` | Stable display-layer entry point |
|
|
240
|
+
| `~/.coding-usage-bar/status.json` | Stable display-layer entry point, including the measured menu bar budget |
|
|
241
|
+
| `~/.coding-usage-bar/compact-mode` | Manual menu bar title width override; absent means `auto` |
|
|
205
242
|
| `~/.coding-usage-bar/codex/latest.json` | Latest normalized Codex usage |
|
|
206
243
|
| `~/.coding-usage-bar/claude/latest.json` | Latest normalized Claude usage after status line ingest |
|
|
207
244
|
| `~/.coding-usage-bar/glm/latest.json` | Latest normalized GLM usage from Zhipu AI quota API |
|
package/README.zh-CN.md
CHANGED
|
@@ -50,6 +50,17 @@ coding-usage-bar status
|
|
|
50
50
|
- Claude Code、Codex 本地优先,不依赖额外遥测服务
|
|
51
51
|
- 展示层只读稳定的 `status.json`,不会在打开菜单时临时访问数据源
|
|
52
52
|
- 接近限额或燃烧节奏异常时发送系统通知
|
|
53
|
+
- 国产套餐规则内置:GLM Coding Plan 与 DeepSeek 的峰谷时段按北京时间计算,法定节假日按国办通知处理,CLI、下拉菜单和卡片都会显示当前是高峰还是谷时、几折、几点切换
|
|
54
|
+
- GLM 新版 Coding Plan 按积分(`CREDIT_LIMIT`)计量时自动识别,标注"按积分计量",5h/7d 百分比不会因为套餐换代而失真。该识别基于现有套餐观测到的 `CREDIT_LIMIT` 形状,尚未用真实 V3 积分制账号实测;V3 用户可在 issue 里贴脱敏的 `coding-usage-bar status --refresh` 输出帮助确认
|
|
55
|
+
|
|
56
|
+
## 峰谷时段
|
|
57
|
+
|
|
58
|
+
| Provider | 高峰(北京时间) | 谷时 | 法定节假日 |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| DeepSeek API | 周一至周五 09:00–12:00、14:00–18:00 | 高峰价的 50% | 全天谷时 |
|
|
61
|
+
| GLM Coding Plan | 周一至周五 14:00–18:00 | 积分按 50% 抵扣 | 官方规则未排除,按工作日字面处理 |
|
|
62
|
+
|
|
63
|
+
依据 [DeepSeek 定价页](https://api-docs.deepseek.com/quick_start/pricing)与 [GLM Coding Plan 概览](https://docs.bigmodel.cn/cn/coding-plan/overview)。2026 年节假日表来自国办发明电〔2025〕7号;调休上班的周末仍按周末处理,因为两家都把规则写成"周一至周五"。限时活动(如双节全天谷时)不建模。峰谷只说明厂商此刻怎么计费,不改变燃烧节奏分析。
|
|
53
64
|
|
|
54
65
|
完整命令、Provider 配置和实现边界请查看[英文 README](README.md)。
|
|
55
66
|
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { formatStatusRows, formatAnalysisDetail, formatIssues, formatProviderMet
|
|
|
6
6
|
import { loadDisplayStatusSnapshot, collectStatusSnapshot } from "./runtime.js";
|
|
7
7
|
import { runDaemonOnce } from "./daemon.js";
|
|
8
8
|
import { doctorHasFailures, formatDoctor, runDoctor, runDoctorFix } from "./doctor.js";
|
|
9
|
-
import { installMenuBar, renderMenuBar, uninstallMenuBar,
|
|
9
|
+
import { installMenuBar, renderMenuBar, uninstallMenuBar, cycleTitleMode } from "./menubar.js";
|
|
10
10
|
import { buildPaths } from "./paths.js";
|
|
11
11
|
import { bakeGlyphAtlases } from "./glyphs.js";
|
|
12
12
|
import { maybePromptForStar } from "./star.js";
|
|
@@ -25,7 +25,7 @@ Usage:
|
|
|
25
25
|
coding-usage-bar menubar render
|
|
26
26
|
coding-usage-bar menubar install [--dry-run]
|
|
27
27
|
coding-usage-bar menubar uninstall [--dry-run]
|
|
28
|
-
coding-usage-bar menubar
|
|
28
|
+
coding-usage-bar menubar cycle-title-mode
|
|
29
29
|
coding-usage-bar menubar bake-glyphs [--dry-run]
|
|
30
30
|
coding-usage-bar ingest claude-statusline
|
|
31
31
|
coding-usage-bar daemon [--once] [--dry-run]
|
|
@@ -118,9 +118,11 @@ async function main() {
|
|
|
118
118
|
console.log(uninstallMenuBar({ dryRun }).join("\n"));
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// `toggle-compact` is the pre-tier name for this action. A dropdown that
|
|
122
|
+
// SwiftBar rendered before the upgrade still carries it, so a click on
|
|
123
|
+
// that stale menu must keep working instead of erroring out.
|
|
124
|
+
if (command === "menubar" && (subcommand === "cycle-title-mode" || subcommand === "toggle-compact")) {
|
|
125
|
+
console.log(cycleTitleMode());
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
126
128
|
if (command === "menubar" && subcommand === "bake-glyphs") {
|
package/dist/codex.d.ts
CHANGED
|
@@ -3,4 +3,6 @@ export declare function usageFromCodexRateLimits(rateLimits: unknown, options: {
|
|
|
3
3
|
observedAt?: string;
|
|
4
4
|
source: string;
|
|
5
5
|
}): ProviderUsage | null;
|
|
6
|
-
export declare function collectCodexUsage(codexHome?: string
|
|
6
|
+
export declare function collectCodexUsage(codexHome?: string, options?: {
|
|
7
|
+
tailChunkBytes?: number;
|
|
8
|
+
}): ProviderUsage;
|
package/dist/codex.js
CHANGED
|
@@ -72,34 +72,71 @@ export function usageFromCodexRateLimits(rateLimits, options) {
|
|
|
72
72
|
windows,
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
const DEFAULT_TAIL_CHUNK_BYTES = 1024 * 1024;
|
|
76
|
+
function candidateFromLine(line, file, fallbackMs) {
|
|
77
|
+
if (!line || !line.includes("rate_limits")) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(line);
|
|
82
|
+
const payload = parsed.payload;
|
|
83
|
+
const rateLimits = payload?.rate_limits ?? parsed.rate_limits;
|
|
84
|
+
const observedMs = parseObservedMs(parsed.timestamp ?? parsed.ts, fallbackMs);
|
|
85
|
+
const usage = usageFromCodexRateLimits(rateLimits, {
|
|
86
|
+
source: file,
|
|
87
|
+
observedAt: new Date(observedMs).toISOString(),
|
|
88
|
+
});
|
|
89
|
+
return usage ? { usage, observedMs } : null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Session rollouts of a long-lived Codex session can exceed V8's max string
|
|
96
|
+
// length (~512 MiB); readFileSync(file, "utf8") then throws and the newest
|
|
97
|
+
// usage silently disappears. Read the file backwards in bounded chunks and stop
|
|
98
|
+
// at the first complete line that carries rate_limits.
|
|
99
|
+
function latestCandidateFromFile(file, chunkBytes) {
|
|
76
100
|
const stat = fs.statSync(file);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
const fd = fs.openSync(file, "r");
|
|
102
|
+
try {
|
|
103
|
+
let position = stat.size;
|
|
104
|
+
let carry = Buffer.alloc(0);
|
|
105
|
+
while (position > 0) {
|
|
106
|
+
const readSize = Math.min(chunkBytes, position);
|
|
107
|
+
position -= readSize;
|
|
108
|
+
const chunk = Buffer.alloc(readSize);
|
|
109
|
+
fs.readSync(fd, chunk, 0, readSize, position);
|
|
110
|
+
const combined = carry.length > 0 ? Buffer.concat([chunk, carry]) : chunk;
|
|
111
|
+
let lineStart = 0;
|
|
112
|
+
if (position > 0) {
|
|
113
|
+
const newline = combined.indexOf(0x0a);
|
|
114
|
+
if (newline === -1) {
|
|
115
|
+
carry = combined;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
carry = combined.subarray(0, newline);
|
|
119
|
+
lineStart = newline + 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
carry = Buffer.alloc(0);
|
|
123
|
+
}
|
|
124
|
+
const lines = combined.subarray(lineStart).toString("utf8").split(/\r?\n/);
|
|
125
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
126
|
+
const candidate = candidateFromLine(lines[i]?.trim() ?? "", file, stat.mtimeMs);
|
|
127
|
+
if (candidate) {
|
|
128
|
+
return candidate;
|
|
129
|
+
}
|
|
94
130
|
}
|
|
95
131
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
fs.closeSync(fd);
|
|
99
135
|
}
|
|
100
136
|
return null;
|
|
101
137
|
}
|
|
102
|
-
export function collectCodexUsage(codexHome = path.join(process.env.HOME ?? "", ".codex")) {
|
|
138
|
+
export function collectCodexUsage(codexHome = path.join(process.env.HOME ?? "", ".codex"), options = {}) {
|
|
139
|
+
const chunkBytes = options.tailChunkBytes ?? DEFAULT_TAIL_CHUNK_BYTES;
|
|
103
140
|
if (!isDir(codexHome)) {
|
|
104
141
|
throw new Error(`Codex usage unavailable: ${codexHome} does not exist`);
|
|
105
142
|
}
|
|
@@ -112,7 +149,7 @@ export function collectCodexUsage(codexHome = path.join(process.env.HOME ?? "",
|
|
|
112
149
|
break;
|
|
113
150
|
}
|
|
114
151
|
try {
|
|
115
|
-
const candidate = latestCandidateFromFile(file);
|
|
152
|
+
const candidate = latestCandidateFromFile(file, chunkBytes);
|
|
116
153
|
if (candidate && (!latest || candidate.observedMs > latest.observedMs)) {
|
|
117
154
|
latest = candidate;
|
|
118
155
|
}
|
package/dist/deepseek.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deepseekPricing } from "./pricing.js";
|
|
1
2
|
const DEFAULT_BASE_URL = "https://api.deepseek.com";
|
|
2
3
|
export async function fetchDeepseekBalance(config) {
|
|
3
4
|
const url = `${DEFAULT_BASE_URL}/user/balance`;
|
|
@@ -19,11 +20,13 @@ export function usageFromDeepseekBalance(balance, options) {
|
|
|
19
20
|
if (!info) {
|
|
20
21
|
return null;
|
|
21
22
|
}
|
|
23
|
+
const observedAt = options.observedAt ?? new Date().toISOString();
|
|
22
24
|
return {
|
|
23
25
|
provider: "deepseek",
|
|
24
26
|
source: options.source,
|
|
25
|
-
observedAt
|
|
27
|
+
observedAt,
|
|
26
28
|
planType: null,
|
|
29
|
+
pricing: deepseekPricing(new Date(observedAt)),
|
|
27
30
|
windows: [],
|
|
28
31
|
balance: {
|
|
29
32
|
total: info.total_balance,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { DisplayGeometry } from "./types.js";
|
|
2
|
+
export interface DisplayProbeReading {
|
|
3
|
+
screenWidthPt: number;
|
|
4
|
+
auxiliaryTopRightPt: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function geometryFromReading(reading: DisplayProbeReading, measuredAt: Date): DisplayGeometry | null;
|
|
7
|
+
export declare function probeDisplayGeometry(measuredAt?: Date): DisplayGeometry | null;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
// The frontmost app's menus own the left half of the menu bar and their width
|
|
3
|
+
// swings with whatever is focused (Chrome ~580pt, Finder ~200pt). Measuring it
|
|
4
|
+
// for real means an Accessibility-permission AX sweep that costs 4.8s, so we
|
|
5
|
+
// reserve a conservative constant instead. Erring high only demotes the title
|
|
6
|
+
// one tier; erring low overflows the bar and macOS hides the item outright.
|
|
7
|
+
const APP_MENU_RESERVE_PT = 600;
|
|
8
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
9
|
+
// AppKit lives in the producer, never in the render path: spawning an
|
|
10
|
+
// AppKit-linked process talks to the WindowServer and stalls the display.
|
|
11
|
+
// The daemon runs this once every 300s, so the ~200ms is free there.
|
|
12
|
+
const PROBE_SCRIPT = `ObjC.import('AppKit');
|
|
13
|
+
// screens[0] is the menu bar display. NSScreen.mainScreen follows the key
|
|
14
|
+
// window instead and would report the wrong screen in a multi-display setup.
|
|
15
|
+
var screen = $.NSScreen.screens.objectAtIndex(0);
|
|
16
|
+
var frame = screen.frame;
|
|
17
|
+
var width = frame.size.width;
|
|
18
|
+
var right = 0;
|
|
19
|
+
try {
|
|
20
|
+
var area = screen.auxiliaryTopRightArea;
|
|
21
|
+
if (area && area.size) {
|
|
22
|
+
right = area.size.width;
|
|
23
|
+
}
|
|
24
|
+
} catch (error) {
|
|
25
|
+
right = 0;
|
|
26
|
+
}
|
|
27
|
+
JSON.stringify({ screenWidthPt: width, auxiliaryTopRightPt: right });`;
|
|
28
|
+
function isPositiveFinite(value) {
|
|
29
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
30
|
+
}
|
|
31
|
+
// Split out from the spawn so the budget policy is testable without a screen.
|
|
32
|
+
export function geometryFromReading(reading, measuredAt) {
|
|
33
|
+
if (!isPositiveFinite(reading.screenWidthPt)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const screenWidthPt = Math.round(reading.screenWidthPt);
|
|
37
|
+
const auxiliary = reading.auxiliaryTopRightPt;
|
|
38
|
+
// A notch splits the bar into two auxiliary areas, so the right-hand one is
|
|
39
|
+
// strictly narrower than the screen. Without a notch macOS may report either
|
|
40
|
+
// a zero rect or the full frame; both mean "no notch, whole bar available".
|
|
41
|
+
const hasNotch = isPositiveFinite(auxiliary) && auxiliary < screenWidthPt - 1;
|
|
42
|
+
const extrasBudgetPt = hasNotch
|
|
43
|
+
? Math.round(auxiliary)
|
|
44
|
+
: Math.max(0, screenWidthPt - APP_MENU_RESERVE_PT);
|
|
45
|
+
return {
|
|
46
|
+
screenWidthPt,
|
|
47
|
+
hasNotch,
|
|
48
|
+
extrasBudgetPt,
|
|
49
|
+
measuredAt: measuredAt.toISOString(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export function probeDisplayGeometry(measuredAt = new Date()) {
|
|
53
|
+
if (process.platform !== "darwin") {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const output = execFileSync("osascript", ["-l", "JavaScript", "-e", PROBE_SCRIPT], {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
60
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
61
|
+
});
|
|
62
|
+
const parsed = JSON.parse(output.trim());
|
|
63
|
+
return geometryFromReading(parsed, measuredAt);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Headless launchd contexts, a locked screen, or a future AppKit change
|
|
67
|
+
// all land here. A missing measurement degrades the title tier; it must
|
|
68
|
+
// never take the usage collection down with it.
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/format.js
CHANGED
|
@@ -62,10 +62,24 @@ export function usageBar(usedPercent, width = 12) {
|
|
|
62
62
|
const usedCells = Math.max(0, Math.min(width, Math.round((usedPercent / 100) * width)));
|
|
63
63
|
return `[${"#".repeat(usedCells)}${"-".repeat(width - usedCells)}] ${Math.round(usedPercent)}%`;
|
|
64
64
|
}
|
|
65
|
+
function pricingRow(usage) {
|
|
66
|
+
if (!usage.pricing) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const tier = usage.pricing.tier === "off_peak" ? `off-peak ${usage.pricing.discountPercent}% off` : "peak, full price";
|
|
70
|
+
return [
|
|
71
|
+
providerLabel(usage.provider).padEnd(8),
|
|
72
|
+
"price".padEnd(8),
|
|
73
|
+
tier.padEnd(19),
|
|
74
|
+
formatDurationUntil(usage.pricing.until).padEnd(10),
|
|
75
|
+
usage.quotaUnit === "credits" ? "credits" : "",
|
|
76
|
+
].join("");
|
|
77
|
+
}
|
|
65
78
|
export function formatStatusRows(usages, analyses) {
|
|
66
79
|
const rows = ["Provider Period Usage Reset State"];
|
|
67
80
|
for (const usage of usages) {
|
|
68
81
|
const analysis = analyses.find((item) => item.provider === usage.provider);
|
|
82
|
+
const price = pricingRow(usage);
|
|
69
83
|
if (usage.balance && usage.windows.length === 0) {
|
|
70
84
|
const currency = usage.balance.currency === "CNY" ? "¥" : "$";
|
|
71
85
|
rows.push([
|
|
@@ -75,6 +89,8 @@ export function formatStatusRows(usages, analyses) {
|
|
|
75
89
|
"".padEnd(10),
|
|
76
90
|
analysis?.state ?? "RAW",
|
|
77
91
|
].join(""));
|
|
92
|
+
if (price)
|
|
93
|
+
rows.push(price);
|
|
78
94
|
continue;
|
|
79
95
|
}
|
|
80
96
|
for (const window of usage.windows) {
|
|
@@ -86,6 +102,8 @@ export function formatStatusRows(usages, analyses) {
|
|
|
86
102
|
analysis?.state ?? "RAW",
|
|
87
103
|
].join(""));
|
|
88
104
|
}
|
|
105
|
+
if (price)
|
|
106
|
+
rows.push(price);
|
|
89
107
|
}
|
|
90
108
|
return rows.join("\n");
|
|
91
109
|
}
|
package/dist/glm.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { makeProviderUsage, normalizeWindow } from "./usage.js";
|
|
2
|
+
import { glmPricing } from "./pricing.js";
|
|
2
3
|
const DEFAULT_BASE_URL = "https://open.bigmodel.cn";
|
|
3
4
|
export async function fetchGlmQuota(config) {
|
|
4
5
|
const baseUrl = config.baseUrl || DEFAULT_BASE_URL;
|
|
@@ -30,15 +31,11 @@ function resetTimeIso(value) {
|
|
|
30
31
|
// usage (same proto-style field omission as Kimi's zero `used`). The zero
|
|
31
32
|
// percentage is a real signal and must not be dropped; a missing reset time
|
|
32
33
|
// falls back to `observedAt`, which renders as "reset due".
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
let five = tokenLimits.find((limit) => limit.unit === 3 && limit.number === 5) ?? null;
|
|
39
|
-
let seven = tokenLimits.find((limit) => limit.unit === 6 && limit.number === 1) ?? null;
|
|
34
|
+
function pickTokenWindowsOfType(limits) {
|
|
35
|
+
let five = limits.find((limit) => limit.unit === 3 && limit.number === 5) ?? null;
|
|
36
|
+
let seven = limits.find((limit) => limit.unit === 6 && limit.number === 1) ?? null;
|
|
40
37
|
if (!five || !seven) {
|
|
41
|
-
const rest =
|
|
38
|
+
const rest = limits
|
|
42
39
|
.filter((limit) => limit !== five && limit !== seven)
|
|
43
40
|
.sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
|
|
44
41
|
five = five ?? rest.shift() ?? null;
|
|
@@ -46,6 +43,19 @@ function pickTokenWindows(limits) {
|
|
|
46
43
|
}
|
|
47
44
|
return five && seven ? { five, seven } : null;
|
|
48
45
|
}
|
|
46
|
+
function pickTokenWindows(limits) {
|
|
47
|
+
// Zhipu migrated Coding Plan quota reporting from TOKENS_LIMIT (token
|
|
48
|
+
// counts) to CREDIT_LIMIT (credit counts, observed live 2026-08-29) with the
|
|
49
|
+
// same unit/number window shapes. Prefer the token signal when both exist;
|
|
50
|
+
// fall back to credits so credit-based plans are not dropped as unusable.
|
|
51
|
+
for (const type of ["TOKENS_LIMIT", "CREDIT_LIMIT"]) {
|
|
52
|
+
const picked = pickTokenWindowsOfType(limits.filter((limit) => limit.type === type));
|
|
53
|
+
if (picked) {
|
|
54
|
+
return { ...picked, unit: type === "CREDIT_LIMIT" ? "credits" : "tokens" };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
49
59
|
export function usageFromGlmQuota(quota, options) {
|
|
50
60
|
if (!quota.success || !quota.data) {
|
|
51
61
|
return null;
|
|
@@ -68,7 +78,7 @@ export function usageFromGlmQuota(quota, options) {
|
|
|
68
78
|
if (!fiveHour || !sevenDay) {
|
|
69
79
|
return null;
|
|
70
80
|
}
|
|
71
|
-
|
|
81
|
+
const usage = makeProviderUsage({
|
|
72
82
|
provider: "glm",
|
|
73
83
|
source: options.source,
|
|
74
84
|
observedAt: options.observedAt,
|
|
@@ -76,6 +86,11 @@ export function usageFromGlmQuota(quota, options) {
|
|
|
76
86
|
fiveHour,
|
|
77
87
|
sevenDay,
|
|
78
88
|
});
|
|
89
|
+
return {
|
|
90
|
+
...usage,
|
|
91
|
+
quotaUnit: picked.unit,
|
|
92
|
+
pricing: glmPricing(new Date(usage.observedAt)),
|
|
93
|
+
};
|
|
79
94
|
}
|
|
80
95
|
export async function collectGlmUsage(config) {
|
|
81
96
|
if (!config.apiKey) {
|
package/dist/install.js
CHANGED
|
@@ -170,7 +170,11 @@ function restartLaunchAgent(plistFile) {
|
|
|
170
170
|
}
|
|
171
171
|
try {
|
|
172
172
|
execFileSync("launchctl", ["bootstrap", domain, plistFile]);
|
|
173
|
-
|
|
173
|
+
// No -k: bootout above already SIGTERMed any old instance, so the kill arm of
|
|
174
|
+
// "kickstart -k" can never fire here — and on macOS 26 (Tahoe) a SIGKILL from
|
|
175
|
+
// kickstart -k can wedge the job in "pended nondemand spawn", silently killing
|
|
176
|
+
// all automatic scheduling for it.
|
|
177
|
+
execFileSync("launchctl", ["kickstart", `${domain}/${LAUNCHD_LABEL}`]);
|
|
174
178
|
return;
|
|
175
179
|
}
|
|
176
180
|
catch {
|
package/dist/menubar.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { RuntimePaths, StatusSnapshot } from "./types.js";
|
|
2
2
|
export declare function resetGlyphSetCache(): void;
|
|
3
|
-
export
|
|
4
|
-
export
|
|
3
|
+
export type TitleTier = "full" | "single" | "icon";
|
|
4
|
+
export type TitleMode = "auto" | TitleTier;
|
|
5
|
+
export declare function readTitleMode(paths?: RuntimePaths): TitleMode;
|
|
6
|
+
export declare function writeTitleMode(mode: TitleMode, paths?: RuntimePaths): TitleMode;
|
|
7
|
+
export declare function cycleTitleMode(paths?: RuntimePaths): TitleMode;
|
|
8
|
+
export declare function pickTitleTier(display: StatusSnapshot["display"], providerCount: number): TitleTier;
|
|
5
9
|
export declare function swiftBarPluginPath(paths?: RuntimePaths): {
|
|
6
10
|
dir: string;
|
|
7
11
|
file: string;
|
package/dist/menubar.js
CHANGED
|
@@ -4,6 +4,7 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { ensureDir, isFile } from "./fs-util.js";
|
|
6
6
|
import { formatProviderLabel, formatResetTime } from "./format.js";
|
|
7
|
+
import { pricingLabel } from "./pricing.js";
|
|
7
8
|
import { buildPaths } from "./paths.js";
|
|
8
9
|
import { stableNodeExecutable } from "./node-runtime.js";
|
|
9
10
|
import { loadDisplayStatusSnapshot } from "./runtime.js";
|
|
@@ -120,27 +121,74 @@ function systemPrefersDark() {
|
|
|
120
121
|
function appCliPath(paths) {
|
|
121
122
|
return path.join(paths.stateDir, "app", "dist", "cli.js");
|
|
122
123
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
const TITLE_MODE_CYCLE = ["auto", "full", "single", "icon"];
|
|
125
|
+
const TITLE_TIER_LABEL = {
|
|
126
|
+
full: "Full",
|
|
127
|
+
single: "Single",
|
|
128
|
+
icon: "Icon",
|
|
129
|
+
};
|
|
130
|
+
// Measured on a notched 13" (2560x1664, 1470pt logical, 645pt right of the
|
|
131
|
+
// notch): the six-provider composite is 511pt wide including SwiftBar's item
|
|
132
|
+
// padding, a single-provider one is 68pt. The system's own extras (clock,
|
|
133
|
+
// Control Center, input menu, Spotlight) claim ~240pt before any third-party
|
|
134
|
+
// icon shows up, so `full` needs a budget well beyond its own width to be
|
|
135
|
+
// safe -- 645pt of notch-side bar cannot hold it even with nothing else
|
|
136
|
+
// installed.
|
|
137
|
+
const FULL_TIER_MIN_BUDGET_PT = 900;
|
|
138
|
+
const SINGLE_TIER_MIN_BUDGET_PT = 350;
|
|
139
|
+
// Name kept from the pre-tier boolean toggle: an existing empty marker file
|
|
140
|
+
// means the user had compact on, which is the icon tier now.
|
|
141
|
+
const TITLE_MODE_FILE = "compact-mode";
|
|
142
|
+
function titleModeFilePath(paths) {
|
|
143
|
+
return path.join(paths.stateDir, TITLE_MODE_FILE);
|
|
126
144
|
}
|
|
127
|
-
export function
|
|
145
|
+
export function readTitleMode(paths = buildPaths()) {
|
|
128
146
|
try {
|
|
129
|
-
|
|
147
|
+
const raw = fs.readFileSync(titleModeFilePath(paths), "utf8").trim();
|
|
148
|
+
if (raw === "") {
|
|
149
|
+
return "icon";
|
|
150
|
+
}
|
|
151
|
+
return TITLE_MODE_CYCLE.includes(raw) ? raw : "auto";
|
|
130
152
|
}
|
|
131
153
|
catch {
|
|
132
|
-
|
|
154
|
+
// Absent file (and any unreadable state) means hands off: let the measured
|
|
155
|
+
// budget decide.
|
|
156
|
+
return "auto";
|
|
133
157
|
}
|
|
134
158
|
}
|
|
135
|
-
export function
|
|
136
|
-
const filePath =
|
|
137
|
-
if (
|
|
159
|
+
export function writeTitleMode(mode, paths = buildPaths()) {
|
|
160
|
+
const filePath = titleModeFilePath(paths);
|
|
161
|
+
if (mode === "auto") {
|
|
138
162
|
fs.rmSync(filePath, { force: true });
|
|
139
|
-
return
|
|
163
|
+
return "auto";
|
|
140
164
|
}
|
|
141
165
|
ensureDir(paths.stateDir);
|
|
142
|
-
fs.writeFileSync(filePath,
|
|
143
|
-
return
|
|
166
|
+
fs.writeFileSync(filePath, `${mode}\n`);
|
|
167
|
+
return mode;
|
|
168
|
+
}
|
|
169
|
+
export function cycleTitleMode(paths = buildPaths()) {
|
|
170
|
+
const current = readTitleMode(paths);
|
|
171
|
+
const next = TITLE_MODE_CYCLE[(TITLE_MODE_CYCLE.indexOf(current) + 1) % TITLE_MODE_CYCLE.length];
|
|
172
|
+
return writeTitleMode(next, paths);
|
|
173
|
+
}
|
|
174
|
+
export function pickTitleTier(display, providerCount) {
|
|
175
|
+
if (providerCount === 0) {
|
|
176
|
+
return "icon";
|
|
177
|
+
}
|
|
178
|
+
const budget = display?.extrasBudgetPt;
|
|
179
|
+
if (typeof budget !== "number" || !Number.isFinite(budget)) {
|
|
180
|
+
// No measurement yet: fresh install, non-macOS host, or a failed probe.
|
|
181
|
+
// Assume the cramped case. Guessing `single` wrong costs detail; guessing
|
|
182
|
+
// `full` wrong costs the entire menu bar item.
|
|
183
|
+
return "single";
|
|
184
|
+
}
|
|
185
|
+
if (budget >= FULL_TIER_MIN_BUDGET_PT) {
|
|
186
|
+
return "full";
|
|
187
|
+
}
|
|
188
|
+
if (budget >= SINGLE_TIER_MIN_BUDGET_PT) {
|
|
189
|
+
return "single";
|
|
190
|
+
}
|
|
191
|
+
return "icon";
|
|
144
192
|
}
|
|
145
193
|
function configuredSwiftBarPluginDir(paths) {
|
|
146
194
|
// Isolation escape hatch: an explicit CODING_USAGE_BAR_PLUGIN_DIR always wins and never
|
|
@@ -577,6 +625,56 @@ function titleIconParams() {
|
|
|
577
625
|
}
|
|
578
626
|
return { sfimage: "flame.fill", sfcolor: RAW_COLOR };
|
|
579
627
|
}
|
|
628
|
+
// Sort is stable per spec, so TITLE_PROVIDER_ORDER survives as the tie-break
|
|
629
|
+
// and the same snapshot always elects the same provider. Byte-stable output is
|
|
630
|
+
// what lets SwiftBar skip the menu rebuild.
|
|
631
|
+
function mostUrgentProvider(providers) {
|
|
632
|
+
return [...providers].sort((left, right) => (STATE_PRIORITY[left.analysis.state] - STATE_PRIORITY[right.analysis.state]))[0];
|
|
633
|
+
}
|
|
634
|
+
// The invariant this whole ladder exists for: every path out of here carries a
|
|
635
|
+
// visual. Missing assets, a PNG failure, an empty tier -- none of them may end
|
|
636
|
+
// with an item that renders as nothing, because "renders as nothing" is
|
|
637
|
+
// indistinguishable from "the tool is broken".
|
|
638
|
+
function titleLine(tier, providers, title, topState, prefersDark) {
|
|
639
|
+
const iconLine = () => line("", {
|
|
640
|
+
sfimage: "flame.fill",
|
|
641
|
+
sfcolor: STATE_COLOR[topState],
|
|
642
|
+
dropdown: false,
|
|
643
|
+
tooltip: title,
|
|
644
|
+
});
|
|
645
|
+
if (tier === "icon") {
|
|
646
|
+
return iconLine();
|
|
647
|
+
}
|
|
648
|
+
const urgent = tier === "single" ? mostUrgentProvider(providers) : undefined;
|
|
649
|
+
const shown = urgent ? [urgent] : providers;
|
|
650
|
+
const image = titleImageValue(shown, prefersDark);
|
|
651
|
+
if (image) {
|
|
652
|
+
return line("", {
|
|
653
|
+
image: image.image,
|
|
654
|
+
width: image.width,
|
|
655
|
+
height: image.height,
|
|
656
|
+
dropdown: false,
|
|
657
|
+
tooltip: title,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
const text = urgent ? titleSegment(urgent) : title;
|
|
661
|
+
return text
|
|
662
|
+
? line(text, { ...titleIconParams(), dropdown: false })
|
|
663
|
+
: iconLine();
|
|
664
|
+
}
|
|
665
|
+
function titleModeLabel(mode, autoTier) {
|
|
666
|
+
return mode === "auto"
|
|
667
|
+
? `Title Auto (${TITLE_TIER_LABEL[autoTier]})`
|
|
668
|
+
: `Title ${TITLE_TIER_LABEL[mode]}`;
|
|
669
|
+
}
|
|
670
|
+
// Carries the "why" behind a degraded title without spending a menu row on it.
|
|
671
|
+
function titleModeTooltip(display, autoTier) {
|
|
672
|
+
if (!display || typeof display.extrasBudgetPt !== "number") {
|
|
673
|
+
return `Menu bar space unknown, assuming ${TITLE_TIER_LABEL[autoTier]}. Click to cycle Auto/Full/Single/Icon.`;
|
|
674
|
+
}
|
|
675
|
+
const shape = display.hasNotch ? "notched display" : "full-width menu bar";
|
|
676
|
+
return `${display.extrasBudgetPt}pt of menu bar space (${shape}) fits ${TITLE_TIER_LABEL[autoTier]}. Click to cycle Auto/Full/Single/Icon.`;
|
|
677
|
+
}
|
|
580
678
|
function anyProviderStale(snapshot) {
|
|
581
679
|
return snapshot.providers.some((item) => item.meta.stale);
|
|
582
680
|
}
|
|
@@ -671,7 +769,9 @@ function cardProviderBlock(item, now) {
|
|
|
671
769
|
darkColor,
|
|
672
770
|
headline: `${currency}${item.usage.balance.total}`,
|
|
673
771
|
rows: [],
|
|
674
|
-
note:
|
|
772
|
+
note: item.usage.pricing
|
|
773
|
+
? `Pay-as-you-go · ${pricingLabel(item.usage.pricing, formatResetTime(item.usage.pricing.until, now))}`
|
|
774
|
+
: "Pay-as-you-go · no windowed quota",
|
|
675
775
|
message: null,
|
|
676
776
|
updated: observedUpdatedLabel(item, now),
|
|
677
777
|
};
|
|
@@ -695,7 +795,9 @@ function cardProviderBlock(item, now) {
|
|
|
695
795
|
darkColor,
|
|
696
796
|
headline: headlinePct ? `${Math.round(headlinePct.usedPercent)}%` : STATE_LABEL[item.analysis.state],
|
|
697
797
|
rows,
|
|
698
|
-
note:
|
|
798
|
+
note: item.usage.pricing
|
|
799
|
+
? `${pricingLabel(item.usage.pricing, formatResetTime(item.usage.pricing.until, now))}${item.usage.quotaUnit === "credits" ? " · credits" : ""}`
|
|
800
|
+
: null,
|
|
699
801
|
message: shortCardMessage(item),
|
|
700
802
|
updated: observedUpdatedLabel(item, now),
|
|
701
803
|
};
|
|
@@ -733,52 +835,35 @@ function usageCardValue(snapshot, providers, glyphs, now, prefersDark) {
|
|
|
733
835
|
// SwiftBar's content guard can skip the menu rebuild and menu bar repaint;
|
|
734
836
|
// never render anything derived from wall-clock age or countdowns here.
|
|
735
837
|
export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = buildPaths(), now = new Date()) {
|
|
736
|
-
const
|
|
838
|
+
const mode = readTitleMode(paths);
|
|
737
839
|
const providers = titleProviders(snapshot);
|
|
738
840
|
const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
|
|
739
|
-
const
|
|
841
|
+
const autoTier = pickTitleTier(snapshot.display, providers.length);
|
|
842
|
+
const tier = mode === "auto" ? autoTier : mode;
|
|
843
|
+
const modeLine = line(titleModeLabel(mode, autoTier), {
|
|
740
844
|
bash: stableNodeExecutable(),
|
|
741
845
|
param1: appCliPath(paths),
|
|
742
846
|
param2: "menubar",
|
|
743
|
-
param3: "
|
|
847
|
+
param3: "cycle-title-mode",
|
|
744
848
|
terminal: false,
|
|
745
849
|
refresh: true,
|
|
746
850
|
color: TEXT_COLOR,
|
|
747
|
-
sfimage:
|
|
748
|
-
|
|
851
|
+
sfimage: "arrow.triangle.2.circlepath",
|
|
852
|
+
tooltip: titleModeTooltip(snapshot.display, autoTier),
|
|
853
|
+
});
|
|
749
854
|
if (!title) {
|
|
750
855
|
return [
|
|
751
856
|
line("Coding Usage Bar No Usage", { sfimage: "flame.fill", sfcolor: RAW_COLOR }),
|
|
752
857
|
"---",
|
|
753
858
|
line("No provider usage available", { color: MUTED_COLOR }),
|
|
754
|
-
|
|
859
|
+
modeLine,
|
|
755
860
|
line("Refresh now", { refresh: true, color: TEXT_COLOR, sfimage: "arrow.clockwise" }),
|
|
756
861
|
].join("\n");
|
|
757
862
|
}
|
|
758
|
-
const topState =
|
|
759
|
-
?? "RAW";
|
|
863
|
+
const topState = mostUrgentProvider(providers)?.analysis.state ?? "RAW";
|
|
760
864
|
const prefersDark = systemPrefersDark();
|
|
761
|
-
const titleImage = titleImageValue(providers, prefersDark);
|
|
762
865
|
const lines = [
|
|
763
|
-
|
|
764
|
-
? line("", {
|
|
765
|
-
sfimage: "flame.fill",
|
|
766
|
-
sfcolor: STATE_COLOR[topState],
|
|
767
|
-
dropdown: false,
|
|
768
|
-
tooltip: title,
|
|
769
|
-
})
|
|
770
|
-
: titleImage
|
|
771
|
-
? line("", {
|
|
772
|
-
image: titleImage.image,
|
|
773
|
-
width: titleImage.width,
|
|
774
|
-
height: titleImage.height,
|
|
775
|
-
dropdown: false,
|
|
776
|
-
tooltip: title,
|
|
777
|
-
})
|
|
778
|
-
: line(title, {
|
|
779
|
-
...titleIconParams(),
|
|
780
|
-
dropdown: false,
|
|
781
|
-
}),
|
|
866
|
+
titleLine(tier, providers, title, topState, prefersDark),
|
|
782
867
|
"---",
|
|
783
868
|
];
|
|
784
869
|
const glyphs = glyphSetFor(paths);
|
|
@@ -821,6 +906,9 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
821
906
|
font: ROW_FONT,
|
|
822
907
|
size: 12,
|
|
823
908
|
}));
|
|
909
|
+
if (item.usage.pricing) {
|
|
910
|
+
lines.push(muted(pricingLabel(item.usage.pricing, formatResetTime(item.usage.pricing.until, now))));
|
|
911
|
+
}
|
|
824
912
|
lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
|
825
913
|
lines.push("---");
|
|
826
914
|
continue;
|
|
@@ -840,6 +928,10 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
840
928
|
if (item.usage.blocked) {
|
|
841
929
|
lines.push(muted(`Blocked ${item.usage.blocked.reason}. 5h/7d numbers above still reflect the window quotas.`));
|
|
842
930
|
}
|
|
931
|
+
if (item.usage.pricing) {
|
|
932
|
+
const unit = item.usage.quotaUnit === "credits" ? " · metered in credits" : "";
|
|
933
|
+
lines.push(muted(`${pricingLabel(item.usage.pricing, formatResetTime(item.usage.pricing.until, now))}${unit}`));
|
|
934
|
+
}
|
|
843
935
|
lines.push(muted(targetLabel(item)));
|
|
844
936
|
lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
|
845
937
|
lines.push("---");
|
|
@@ -866,7 +958,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
866
958
|
if (visibleIssues.length > 0) {
|
|
867
959
|
lines.push("---");
|
|
868
960
|
}
|
|
869
|
-
lines.push(
|
|
961
|
+
lines.push(modeLine);
|
|
870
962
|
lines.push(line("Refresh now", {
|
|
871
963
|
refresh: true,
|
|
872
964
|
color: TEXT_COLOR,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { PricingInfo } from "./types.js";
|
|
2
|
+
export declare function isCnPublicHoliday(date: Date): boolean;
|
|
3
|
+
export declare function deepseekPricing(now?: Date): PricingInfo;
|
|
4
|
+
export declare function glmPricing(now?: Date): PricingInfo;
|
|
5
|
+
export declare function pricingLabel(pricing: PricingInfo, until: string): string;
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Peak / off-peak pricing tiers for the two providers that publish a clock-based
|
|
2
|
+
// discount. Both rules are stated by the vendor in Beijing time (UTC+8, no DST),
|
|
3
|
+
// so the arithmetic below deliberately avoids the host time zone.
|
|
4
|
+
//
|
|
5
|
+
// DeepSeek (api-docs.deepseek.com/quick_start/pricing): peak is 01:00-04:00 and
|
|
6
|
+
// 06:00-10:00 UTC, Monday through Friday, excluding Chinese public holidays;
|
|
7
|
+
// every other hour is off-peak at 50% of the peak rate. In Beijing time that is
|
|
8
|
+
// 09:00-12:00 and 14:00-18:00.
|
|
9
|
+
//
|
|
10
|
+
// GLM Coding Plan V3 (docs.bigmodel.cn/cn/coding-plan/overview): 高峰时段 is
|
|
11
|
+
// 每周一至周五的 14:00~18:00 (UTC+8) at the base credit deduction; all other
|
|
12
|
+
// hours deduct 50% of the base credits. The page does not exclude public
|
|
13
|
+
// holidays, and time-boxed promotions (e.g. all-day off-peak during a holiday
|
|
14
|
+
// campaign) are not modelled here: the tier follows the standing rule only.
|
|
15
|
+
const BEIJING_OFFSET_MINUTES = 8 * 60;
|
|
16
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
17
|
+
const LOOKAHEAD_DAYS = 40;
|
|
18
|
+
// Rest days from 国务院办公厅关于2026年部分节假日安排的通知 (国办发明电〔2025〕7号,
|
|
19
|
+
// 2025-11-04). Adjusted working weekends (调休上班) are intentionally not
|
|
20
|
+
// promoted to weekdays: both vendors phrase the peak rule as Monday-Friday.
|
|
21
|
+
const CN_PUBLIC_HOLIDAYS = {
|
|
22
|
+
2026: new Set([
|
|
23
|
+
"01-01", "01-02", "01-03",
|
|
24
|
+
"02-15", "02-16", "02-17", "02-18", "02-19", "02-20", "02-21", "02-22", "02-23",
|
|
25
|
+
"04-04", "04-05", "04-06",
|
|
26
|
+
"05-01", "05-02", "05-03", "05-04", "05-05",
|
|
27
|
+
"06-19", "06-20", "06-21",
|
|
28
|
+
"09-25", "09-26", "09-27",
|
|
29
|
+
"10-01", "10-02", "10-03", "10-04", "10-05", "10-06", "10-07",
|
|
30
|
+
]),
|
|
31
|
+
};
|
|
32
|
+
const DEEPSEEK_RULE = {
|
|
33
|
+
peakSegments: [[9 * 60, 12 * 60], [14 * 60, 18 * 60]],
|
|
34
|
+
excludePublicHolidays: true,
|
|
35
|
+
offPeakDiscountPercent: 50,
|
|
36
|
+
rule: "DeepSeek API: peak Mon-Fri 09:00-12:00 & 14:00-18:00 Asia/Shanghai excluding CN public holidays; off-peak 50% off",
|
|
37
|
+
};
|
|
38
|
+
const GLM_RULE = {
|
|
39
|
+
peakSegments: [[14 * 60, 18 * 60]],
|
|
40
|
+
excludePublicHolidays: false,
|
|
41
|
+
offPeakDiscountPercent: 50,
|
|
42
|
+
rule: "GLM Coding Plan: peak Mon-Fri 14:00-18:00 Asia/Shanghai; off-peak credits deducted at 50%",
|
|
43
|
+
};
|
|
44
|
+
function beijingParts(date) {
|
|
45
|
+
const shifted = new Date(date.getTime() + BEIJING_OFFSET_MINUTES * 60_000);
|
|
46
|
+
return {
|
|
47
|
+
year: shifted.getUTCFullYear(),
|
|
48
|
+
month: shifted.getUTCMonth() + 1,
|
|
49
|
+
day: shifted.getUTCDate(),
|
|
50
|
+
weekday: shifted.getUTCDay(),
|
|
51
|
+
minutes: shifted.getUTCHours() * 60 + shifted.getUTCMinutes(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function beijingInstant(year, month, day, minutes) {
|
|
55
|
+
return new Date(Date.UTC(year, month - 1, day, 0, minutes - BEIJING_OFFSET_MINUTES));
|
|
56
|
+
}
|
|
57
|
+
export function isCnPublicHoliday(date) {
|
|
58
|
+
const parts = beijingParts(date);
|
|
59
|
+
const key = `${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
|
|
60
|
+
return CN_PUBLIC_HOLIDAYS[parts.year]?.has(key) ?? false;
|
|
61
|
+
}
|
|
62
|
+
function tierAt(rule, date) {
|
|
63
|
+
const parts = beijingParts(date);
|
|
64
|
+
if (parts.weekday === 0 || parts.weekday === 6) {
|
|
65
|
+
return "off_peak";
|
|
66
|
+
}
|
|
67
|
+
if (rule.excludePublicHolidays && isCnPublicHoliday(date)) {
|
|
68
|
+
return "off_peak";
|
|
69
|
+
}
|
|
70
|
+
const inPeak = rule.peakSegments.some(([start, end]) => parts.minutes >= start && parts.minutes < end);
|
|
71
|
+
return inPeak ? "peak" : "off_peak";
|
|
72
|
+
}
|
|
73
|
+
// The tier can only flip at a segment edge or at midnight, so walking those
|
|
74
|
+
// instants day by day finds the next change without minute-level scanning.
|
|
75
|
+
function nextTierChange(rule, date) {
|
|
76
|
+
const current = tierAt(rule, date);
|
|
77
|
+
const start = beijingParts(date);
|
|
78
|
+
const edges = Array.from(new Set([0, ...rule.peakSegments.flatMap(([a, b]) => [a, b])]))
|
|
79
|
+
.filter((m) => m < MINUTES_PER_DAY)
|
|
80
|
+
.sort((a, b) => a - b);
|
|
81
|
+
for (let offset = 0; offset <= LOOKAHEAD_DAYS; offset += 1) {
|
|
82
|
+
for (const edge of edges) {
|
|
83
|
+
const candidate = beijingInstant(start.year, start.month, start.day + offset, edge);
|
|
84
|
+
if (candidate.getTime() <= date.getTime()) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (tierAt(rule, candidate) !== current) {
|
|
88
|
+
return candidate;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Unreachable with the rules above (every week has weekdays); keep a sane bound.
|
|
93
|
+
return beijingInstant(start.year, start.month, start.day + LOOKAHEAD_DAYS, 0);
|
|
94
|
+
}
|
|
95
|
+
function pricingFor(rule, date) {
|
|
96
|
+
const tier = tierAt(rule, date);
|
|
97
|
+
return {
|
|
98
|
+
tier,
|
|
99
|
+
discountPercent: tier === "off_peak" ? rule.offPeakDiscountPercent : 0,
|
|
100
|
+
until: nextTierChange(rule, date).toISOString(),
|
|
101
|
+
timezone: "Asia/Shanghai",
|
|
102
|
+
rule: rule.rule,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
export function deepseekPricing(now = new Date()) {
|
|
106
|
+
return pricingFor(DEEPSEEK_RULE, now);
|
|
107
|
+
}
|
|
108
|
+
export function glmPricing(now = new Date()) {
|
|
109
|
+
return pricingFor(GLM_RULE, now);
|
|
110
|
+
}
|
|
111
|
+
export function pricingLabel(pricing, until) {
|
|
112
|
+
return pricing.tier === "off_peak"
|
|
113
|
+
? `Off-peak · ${pricing.discountPercent}% off until ${until}`
|
|
114
|
+
: `Peak · full price until ${until}`;
|
|
115
|
+
}
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BurnAnalysis, BurnProfile, ProviderUsage, RuntimePaths, StatusIssue, StatusSnapshot } from "./types.js";
|
|
1
|
+
import { BurnAnalysis, BurnProfile, DisplayGeometry, ProviderUsage, RuntimePaths, StatusIssue, StatusSnapshot } from "./types.js";
|
|
2
2
|
export declare function loadFixtureUsages(fixturesDir: string): ProviderUsage[];
|
|
3
3
|
export declare function collectLocalState(options?: {
|
|
4
4
|
fixtures?: boolean;
|
|
@@ -18,6 +18,7 @@ export declare function commitCollectedStatusSnapshot(usages: ProviderUsage[], i
|
|
|
18
18
|
profile?: BurnProfile;
|
|
19
19
|
generatedAt?: Date;
|
|
20
20
|
fixtureSamples?: Map<string, ProviderUsage[]>;
|
|
21
|
+
display?: DisplayGeometry | null;
|
|
21
22
|
}): StatusSnapshot;
|
|
22
23
|
export declare function collectStatusSnapshot(options?: {
|
|
23
24
|
fixtures?: boolean;
|
package/dist/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { collectCodexUsage } from "./codex.js";
|
|
|
6
6
|
import { readJsonFile } from "./fs-util.js";
|
|
7
7
|
import { collectGlmUsage } from "./glm.js";
|
|
8
8
|
import { collectDeepseekUsage } from "./deepseek.js";
|
|
9
|
+
import { probeDisplayGeometry } from "./display-probe.js";
|
|
9
10
|
import { collectKimiUsage, resolveKimiConfig } from "./kimi.js";
|
|
10
11
|
import { collectMinimaxUsage } from "./minimax.js";
|
|
11
12
|
import { buildPaths } from "./paths.js";
|
|
@@ -258,6 +259,10 @@ export function commitCollectedStatusSnapshot(usages, issues, options = {}) {
|
|
|
258
259
|
fixtureSamples: options.fixtureSamples,
|
|
259
260
|
issues,
|
|
260
261
|
});
|
|
262
|
+
// Measured outside the lock: it spawns a process and the lock guards a file
|
|
263
|
+
// write. A failed probe keeps the previous reading rather than blanking it,
|
|
264
|
+
// so one bad launchd context cannot demote the title tier.
|
|
265
|
+
const display = options.display === undefined ? probeDisplayGeometry(now) : options.display;
|
|
261
266
|
return withStatusSnapshotLock(paths, () => {
|
|
262
267
|
const existing = loadStatusSnapshot(paths);
|
|
263
268
|
const cached = reconcileLatestUsages(usages, paths);
|
|
@@ -270,6 +275,7 @@ export function commitCollectedStatusSnapshot(usages, issues, options = {}) {
|
|
|
270
275
|
snapshot = replaceStatusSnapshotUsage(snapshot, provider.usage, now);
|
|
271
276
|
}
|
|
272
277
|
}
|
|
278
|
+
snapshot = { ...snapshot, display: display ?? existing?.display ?? null };
|
|
273
279
|
saveStatusSnapshot(snapshot, paths);
|
|
274
280
|
return snapshot;
|
|
275
281
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -8,11 +8,20 @@ export interface UsageWindow {
|
|
|
8
8
|
usedPercent: number;
|
|
9
9
|
resetsAt: string;
|
|
10
10
|
}
|
|
11
|
+
export interface PricingInfo {
|
|
12
|
+
tier: "peak" | "off_peak";
|
|
13
|
+
discountPercent: number;
|
|
14
|
+
until: string;
|
|
15
|
+
timezone: string;
|
|
16
|
+
rule: string;
|
|
17
|
+
}
|
|
11
18
|
export interface ProviderUsage {
|
|
12
19
|
provider: ProviderId;
|
|
13
20
|
source: string;
|
|
14
21
|
observedAt: string;
|
|
15
22
|
planType?: string | null;
|
|
23
|
+
quotaUnit?: "tokens" | "credits";
|
|
24
|
+
pricing?: PricingInfo;
|
|
16
25
|
windows: UsageWindow[];
|
|
17
26
|
balance?: {
|
|
18
27
|
total: string;
|
|
@@ -96,11 +105,18 @@ export interface StatusIssue {
|
|
|
96
105
|
code: string;
|
|
97
106
|
message: string;
|
|
98
107
|
}
|
|
108
|
+
export interface DisplayGeometry {
|
|
109
|
+
screenWidthPt: number;
|
|
110
|
+
hasNotch: boolean;
|
|
111
|
+
extrasBudgetPt: number;
|
|
112
|
+
measuredAt: string;
|
|
113
|
+
}
|
|
99
114
|
export interface StatusSnapshot {
|
|
100
115
|
generatedAt: string;
|
|
101
116
|
profile: BurnProfile;
|
|
102
117
|
providers: ProviderStatus[];
|
|
103
118
|
issues: StatusIssue[];
|
|
119
|
+
display?: DisplayGeometry | null;
|
|
104
120
|
}
|
|
105
121
|
export interface BurnConfig {
|
|
106
122
|
providers: ProviderId[];
|