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 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, toggleCompactMode } from "./menubar.js";
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 toggle-compact
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
- if (command === "menubar" && subcommand === "toggle-compact") {
122
- const isCompact = toggleCompactMode();
123
- console.log(isCompact ? "Compact" : "Expanded");
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): ProviderUsage;
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
- function latestCandidateFromFile(file) {
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 lines = fs.readFileSync(file, "utf8").split(/\r?\n/);
78
- for (let i = lines.length - 1; i >= 0; i -= 1) {
79
- const line = lines[i]?.trim();
80
- if (!line || !line.includes("rate_limits")) {
81
- continue;
82
- }
83
- try {
84
- const parsed = JSON.parse(line);
85
- const payload = parsed.payload;
86
- const rateLimits = payload?.rate_limits ?? parsed.rate_limits;
87
- const observedMs = parseObservedMs(parsed.timestamp ?? parsed.ts, stat.mtimeMs);
88
- const usage = usageFromCodexRateLimits(rateLimits, {
89
- source: file,
90
- observedAt: new Date(observedMs).toISOString(),
91
- });
92
- if (usage) {
93
- return { usage, observedMs };
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
- catch {
97
- continue;
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: options.observedAt ?? new Date().toISOString(),
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 pickTokenWindows(limits) {
34
- const tokenLimits = limits.filter((limit) => limit.type === "TOKENS_LIMIT");
35
- if (tokenLimits.length < 2) {
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 = tokenLimits
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
- return makeProviderUsage({
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
- execFileSync("launchctl", ["kickstart", "-k", `${domain}/${LAUNCHD_LABEL}`]);
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 declare function readCompactMode(paths?: RuntimePaths): boolean;
4
- export declare function toggleCompactMode(paths?: RuntimePaths): boolean;
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 COMPACT_MODE_FILE = "compact-mode";
124
- function compactModeFilePath(paths) {
125
- return path.join(paths.stateDir, COMPACT_MODE_FILE);
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 readCompactMode(paths = buildPaths()) {
145
+ export function readTitleMode(paths = buildPaths()) {
128
146
  try {
129
- return fs.existsSync(compactModeFilePath(paths));
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
- return false;
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 toggleCompactMode(paths = buildPaths()) {
136
- const filePath = compactModeFilePath(paths);
137
- if (fs.existsSync(filePath)) {
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 false;
163
+ return "auto";
140
164
  }
141
165
  ensureDir(paths.stateDir);
142
- fs.writeFileSync(filePath, "");
143
- return true;
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: "Pay-as-you-go · no windowed quota",
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: null,
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 compact = readCompactMode(paths);
838
+ const mode = readTitleMode(paths);
737
839
  const providers = titleProviders(snapshot);
738
840
  const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
739
- const toggleParams = {
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: "toggle-compact",
847
+ param3: "cycle-title-mode",
744
848
  terminal: false,
745
849
  refresh: true,
746
850
  color: TEXT_COLOR,
747
- sfimage: compact ? "rectangle.expand.vertical" : "rectangle.compress.vertical",
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
- line(compact ? "Expand" : "Collapse", toggleParams),
859
+ modeLine,
755
860
  line("Refresh now", { refresh: true, color: TEXT_COLOR, sfimage: "arrow.clockwise" }),
756
861
  ].join("\n");
757
862
  }
758
- const topState = [...providers].sort((left, right) => (STATE_PRIORITY[left.analysis.state] - STATE_PRIORITY[right.analysis.state]))[0]?.analysis.state
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
- compact
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(line(compact ? "Expand" : "Collapse", toggleParams));
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;
@@ -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[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "macOS menu bar usage monitor for Claude Code, Codex, GLM, DeepSeek, and MiniMax.",
5
5
  "author": "hanzhangzzz",
6
6
  "license": "MIT",