coding-usage-bar 0.4.1 → 0.5.1
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 +37 -2
- package/README.zh-CN.md +11 -1
- package/dist/cli.js +21 -7
- package/dist/codex.d.ts +3 -1
- package/dist/codex.js +60 -23
- package/dist/display-probe.d.ts +7 -0
- package/dist/display-probe.js +71 -0
- package/dist/doctor.d.ts +20 -0
- package/dist/doctor.js +85 -5
- package/dist/glm.js +17 -8
- package/dist/install.js +8 -2
- package/dist/menubar.d.ts +21 -2
- package/dist/menubar.js +199 -51
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +6 -0
- package/dist/types.d.ts +7 -0
- package/docs/assets/demo.gif +0 -0
- package/docs/assets/hero.png +0 -0
- package/package.json +2 -2
- package/assets/screenshot-20260714-160548.png +0 -0
- package/assets/screenshot-20260720-153409.png +0 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Claude Code and Codex data stays local and is read from files those tools alread
|
|
|
17
17
|
|
|
18
18
|
## Live SwiftBar Menu
|
|
19
19
|
|
|
20
|
-
The
|
|
20
|
+
The menu bar title and the dropdown panel are captured from the live SwiftBar plugin and placed on a plain backdrop. The animation reproduces opening and closing that menu without altering the captured usage data.
|
|
21
21
|
|
|
22
22
|
<p align="center">
|
|
23
23
|
<img src="docs/assets/demo.gif" alt="Opening the live Coding Usage Bar SwiftBar menu with Codex, Claude, GLM, DeepSeek, MiniMax, and Kimi usage" width="800">
|
|
@@ -33,6 +33,22 @@ coding-usage-bar status
|
|
|
33
33
|
|
|
34
34
|
`install` creates a local runtime at `~/.coding-usage-bar/app`, a user-level CLI shim at `~/.local/bin/coding-usage-bar`, a launchd agent, a default config file, and a SwiftBar menu bar plugin. Make sure `~/.local/bin` is in your `PATH`.
|
|
35
35
|
|
|
36
|
+
## Troubleshooting
|
|
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
|
+
|
|
44
|
+
**Menu bar icon not showing after install?**
|
|
45
|
+
|
|
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.
|
|
47
|
+
2. macOS may fold menu bar icons away: check System Settings → Control Center → Menu Bar.
|
|
48
|
+
3. If macOS showed an Automation permission prompt (System Events) and it was denied, SwiftBar cannot auto-start at login: allow it under System Settings → Privacy & Security → Automation, then run `coding-usage-bar install` again.
|
|
49
|
+
|
|
50
|
+
`coding-usage-bar install` verifies at the end that the plugin actually renders and prints `Menu bar ready`; if that line is missing, the numbered steps it prints tell you exactly what to do.
|
|
51
|
+
|
|
36
52
|
## Commands
|
|
37
53
|
|
|
38
54
|
| Command | Purpose |
|
|
@@ -40,10 +56,12 @@ coding-usage-bar status
|
|
|
40
56
|
| `coding-usage-bar install` | Install runtime, launchd checker, SwiftBar host/plugin, and Claude ingest when safe |
|
|
41
57
|
| `coding-usage-bar uninstall` | Remove Coding Usage Bar managed launchd/status line/plugin config |
|
|
42
58
|
| `coding-usage-bar doctor` | Check local Codex/Claude/GLM usage sources and notification backend |
|
|
59
|
+
| `coding-usage-bar doctor --fix` | Repair common issues: missing SwiftBar/plugin, SwiftBar not running, login item, status data |
|
|
43
60
|
| `coding-usage-bar status` | Print the rolling windows reported by each account and their pacing state from `~/.coding-usage-bar/status.json` |
|
|
44
61
|
| `coding-usage-bar status --json` | Print the same status snapshot written to `~/.coding-usage-bar/status.json` |
|
|
45
62
|
| `coding-usage-bar status --refresh` | Re-collect local usage before printing status |
|
|
46
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` |
|
|
47
65
|
| `coding-usage-bar menubar install` | Install the SwiftBar plugin wrapper |
|
|
48
66
|
| `coding-usage-bar menubar uninstall` | Remove the Coding Usage Bar managed SwiftBar plugin |
|
|
49
67
|
| `coding-usage-bar ingest claude-statusline` | Read Claude Code status line JSON from stdin and cache usage |
|
|
@@ -182,6 +200,22 @@ The compact menu bar title and dropdown use recognizable Provider marks for Code
|
|
|
182
200
|
{Codex icon} 5H:14%,7D:67% │ {Claude icon} 5H:24%,7D:74% │ {GLM icon} 5H:36%,7D:7%
|
|
183
201
|
```
|
|
184
202
|
|
|
203
|
+
### Title width
|
|
204
|
+
|
|
205
|
+
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:
|
|
206
|
+
|
|
207
|
+
| Tier | Width | Shows |
|
|
208
|
+
|------|-------|-------|
|
|
209
|
+
| `full` | ~511pt | Every provider with its own mark and bars |
|
|
210
|
+
| `single` | ~68pt | Only the provider closest to trouble |
|
|
211
|
+
| `icon` | ~35pt | A state-colored flame; the full title moves to the tooltip |
|
|
212
|
+
|
|
213
|
+
`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.
|
|
214
|
+
|
|
215
|
+
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.
|
|
216
|
+
|
|
217
|
+
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.
|
|
218
|
+
|
|
185
219
|
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.
|
|
186
220
|
|
|
187
221
|
## Runtime Files
|
|
@@ -190,7 +224,8 @@ SwiftBar only supports one bitmap image on a single stable title item, so Coding
|
|
|
190
224
|
|------|---------|
|
|
191
225
|
| `~/.coding-usage-bar/app/` | Stable runtime copy used by launchd, Claude ingest hints, and SwiftBar |
|
|
192
226
|
| `~/.coding-usage-bar/config.json` | Provider selection, default `["codex", "claude", "glm", "deepseek", "minimax", "kimi"]` |
|
|
193
|
-
| `~/.coding-usage-bar/status.json` | Stable display-layer entry point |
|
|
227
|
+
| `~/.coding-usage-bar/status.json` | Stable display-layer entry point, including the measured menu bar budget |
|
|
228
|
+
| `~/.coding-usage-bar/compact-mode` | Manual menu bar title width override; absent means `auto` |
|
|
194
229
|
| `~/.coding-usage-bar/codex/latest.json` | Latest normalized Codex usage |
|
|
195
230
|
| `~/.coding-usage-bar/claude/latest.json` | Latest normalized Claude usage after status line ingest |
|
|
196
231
|
| `~/.coding-usage-bar/glm/latest.json` | Latest normalized GLM usage from Zhipu AI quota API |
|
package/README.zh-CN.md
CHANGED
|
@@ -12,7 +12,7 @@ Claude Code 与 Codex 的数据直接读取它们在本机产生的文件,不
|
|
|
12
12
|
|
|
13
13
|
## 真实 SwiftBar 菜单
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
菜单栏标题与下拉面板均截自正在运行的 SwiftBar 插件,仅叠加纯色桌面背景;动画复现菜单的展开与收起,不修改截图中的用量数据。
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
18
|
<img src="docs/assets/demo.gif" alt="打开 Coding Usage Bar 的真实 SwiftBar 菜单,查看 Codex、Claude、GLM、DeepSeek、MiniMax 和 Kimi 用量" width="800">
|
|
@@ -33,6 +33,16 @@ coding-usage-bar status
|
|
|
33
33
|
- 每 5 分钟采集一次用量的 launchd 任务
|
|
34
34
|
- 写入 SwiftBar 当前插件目录的菜单栏插件
|
|
35
35
|
|
|
36
|
+
安装结束时会自动验收菜单栏是否就绪,最后一行出现 `Menu bar ready` 即表示图标已出现在菜单栏右上角。
|
|
37
|
+
|
|
38
|
+
## 常见问题
|
|
39
|
+
|
|
40
|
+
**安装后菜单栏没有图标?**
|
|
41
|
+
|
|
42
|
+
1. 执行 `coding-usage-bar doctor --fix`:自动重启 SwiftBar、补装菜单栏插件、补加 SwiftBar 登录项、补采 status 数据。
|
|
43
|
+
2. macOS 可能把菜单栏图标折叠隐藏了:系统设置 → 控制中心 → 菜单栏,检查隐藏区。
|
|
44
|
+
3. 如果 macOS 弹过 System Events 的"自动化"权限框且被拒绝,SwiftBar 将无法开机自启:在 系统设置 → 隐私与安全性 → 自动化 中放行,再执行一次 `coding-usage-bar install`。
|
|
45
|
+
|
|
36
46
|
## 核心特点
|
|
37
47
|
|
|
38
48
|
- 一个菜单栏同时查看多个 AI 编程 Provider
|
package/dist/cli.js
CHANGED
|
@@ -5,8 +5,8 @@ import { saveUsage } from "./store.js";
|
|
|
5
5
|
import { formatStatusRows, formatAnalysisDetail, formatIssues, formatProviderMeta } from "./format.js";
|
|
6
6
|
import { loadDisplayStatusSnapshot, collectStatusSnapshot } from "./runtime.js";
|
|
7
7
|
import { runDaemonOnce } from "./daemon.js";
|
|
8
|
-
import { doctorHasFailures, formatDoctor, runDoctor } from "./doctor.js";
|
|
9
|
-
import { installMenuBar, renderMenuBar, uninstallMenuBar,
|
|
8
|
+
import { doctorHasFailures, formatDoctor, runDoctor, runDoctorFix } from "./doctor.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";
|
|
@@ -20,12 +20,12 @@ function printHelp() {
|
|
|
20
20
|
Usage:
|
|
21
21
|
coding-usage-bar install [--dry-run]
|
|
22
22
|
coding-usage-bar uninstall [--dry-run]
|
|
23
|
-
coding-usage-bar doctor [--dry-run]
|
|
23
|
+
coding-usage-bar doctor [--dry-run] [--fix]
|
|
24
24
|
coding-usage-bar status [--fixtures] [--json] [--refresh]
|
|
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]
|
|
@@ -57,9 +57,21 @@ async function main() {
|
|
|
57
57
|
return;
|
|
58
58
|
}
|
|
59
59
|
if (command === "doctor") {
|
|
60
|
+
const fix = hasFlag(args, "--fix");
|
|
61
|
+
if (fix) {
|
|
62
|
+
const fixes = await runDoctorFix({ dryRun });
|
|
63
|
+
if (fixes.length > 0) {
|
|
64
|
+
console.log(fixes.join("\n"));
|
|
65
|
+
console.log("");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
60
68
|
const checks = runDoctor({ dryRun });
|
|
61
69
|
console.log(formatDoctor(checks));
|
|
62
70
|
if (doctorHasFailures(checks)) {
|
|
71
|
+
if (!fix) {
|
|
72
|
+
console.log("");
|
|
73
|
+
console.log("Run coding-usage-bar doctor --fix to repair common issues automatically.");
|
|
74
|
+
}
|
|
63
75
|
process.exitCode = 1;
|
|
64
76
|
}
|
|
65
77
|
return;
|
|
@@ -106,9 +118,11 @@ async function main() {
|
|
|
106
118
|
console.log(uninstallMenuBar({ dryRun }).join("\n"));
|
|
107
119
|
return;
|
|
108
120
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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());
|
|
112
126
|
return;
|
|
113
127
|
}
|
|
114
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
|
}
|
|
@@ -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/doctor.d.ts
CHANGED
|
@@ -2,5 +2,25 @@ import { DoctorCheck } from "./types.js";
|
|
|
2
2
|
export declare function runDoctor(options?: {
|
|
3
3
|
dryRun?: boolean;
|
|
4
4
|
}): DoctorCheck[];
|
|
5
|
+
export interface DoctorFixProbes {
|
|
6
|
+
swiftBarInstalled?: () => boolean;
|
|
7
|
+
pluginPresent?: () => boolean;
|
|
8
|
+
swiftBarRunning?: () => boolean;
|
|
9
|
+
swiftBarInLoginItems?: () => boolean | null;
|
|
10
|
+
statusFilePresent?: () => boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface DoctorFixEffects {
|
|
13
|
+
installSwiftBar?: () => string[];
|
|
14
|
+
installPlugin?: () => string[];
|
|
15
|
+
launchSwiftBar?: () => string[];
|
|
16
|
+
addLoginItem?: () => string[];
|
|
17
|
+
collectStatus?: () => Promise<string[]>;
|
|
18
|
+
}
|
|
19
|
+
export interface DoctorFixOptions {
|
|
20
|
+
dryRun?: boolean;
|
|
21
|
+
probes?: DoctorFixProbes;
|
|
22
|
+
effects?: DoctorFixEffects;
|
|
23
|
+
}
|
|
24
|
+
export declare function runDoctorFix(options?: DoctorFixOptions): Promise<string[]>;
|
|
5
25
|
export declare function formatDoctor(checks: DoctorCheck[]): string;
|
|
6
26
|
export declare function doctorHasFailures(checks: DoctorCheck[]): boolean;
|
package/dist/doctor.js
CHANGED
|
@@ -7,7 +7,8 @@ import { isDir, isFile } from "./fs-util.js";
|
|
|
7
7
|
import { resolveKimiConfig } from "./kimi.js";
|
|
8
8
|
import { buildPaths, providerLatestPath } from "./paths.js";
|
|
9
9
|
import { notificationBackend } from "./notifier.js";
|
|
10
|
-
import { isSwiftBarInstalled, isSwiftBarRunning, swiftBarPluginPath } from "./menubar.js";
|
|
10
|
+
import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, isSwiftBarInstalled, isSwiftBarInLoginItems, isSwiftBarRunning, openSwiftBar, swiftBarPluginPath, } from "./menubar.js";
|
|
11
|
+
import { runDaemonOnce } from "./daemon.js";
|
|
11
12
|
function stableClaudeIngestHint() {
|
|
12
13
|
const paths = buildPaths();
|
|
13
14
|
return `printf "%s" "$input" | node "${paths.stateDir}/app/dist/cli.js" ingest claude-statusline >/dev/null`;
|
|
@@ -240,17 +241,96 @@ export function runDoctor(options = {}) {
|
|
|
240
241
|
const swiftBarRunning = pluginPresent && isSwiftBarRunning();
|
|
241
242
|
checks.push({
|
|
242
243
|
name: "Menu bar",
|
|
243
|
-
ok: pluginPresent,
|
|
244
|
+
ok: pluginPresent && swiftBarRunning,
|
|
244
245
|
message: swiftBarInstalled
|
|
245
246
|
? isFile(swiftBarPlugin.file)
|
|
246
247
|
? swiftBarRunning
|
|
247
248
|
? `SwiftBar plugin installed and SwiftBar running: ${swiftBarPlugin.file}`
|
|
248
|
-
:
|
|
249
|
-
:
|
|
250
|
-
: "SwiftBar not found;
|
|
249
|
+
: "SwiftBar plugin installed but SwiftBar is not running (menu bar item hidden); run: coding-usage-bar doctor --fix"
|
|
250
|
+
: "SwiftBar found; run: coding-usage-bar doctor --fix"
|
|
251
|
+
: "SwiftBar not found; run: coding-usage-bar doctor --fix (installs SwiftBar via Homebrew)",
|
|
252
|
+
});
|
|
253
|
+
if (process.platform === "darwin") {
|
|
254
|
+
if (options.dryRun) {
|
|
255
|
+
checks.push({
|
|
256
|
+
name: "SwiftBar login item",
|
|
257
|
+
ok: true,
|
|
258
|
+
message: "[dry-run] would check whether SwiftBar auto-starts at login",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
else if (!swiftBarInstalled) {
|
|
262
|
+
checks.push({
|
|
263
|
+
name: "SwiftBar login item",
|
|
264
|
+
ok: true,
|
|
265
|
+
message: "skipped (SwiftBar not installed)",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const inLoginItems = isSwiftBarInLoginItems();
|
|
270
|
+
checks.push({
|
|
271
|
+
name: "SwiftBar login item",
|
|
272
|
+
ok: inLoginItems !== false,
|
|
273
|
+
message: inLoginItems === null
|
|
274
|
+
? "could not verify (Automation permission denied?); if the icon disappears after reboot, check System Settings → General → Login Items"
|
|
275
|
+
: inLoginItems
|
|
276
|
+
? "SwiftBar auto-starts at login"
|
|
277
|
+
: "not in login items; the menu bar icon disappears after reboot. Run: coding-usage-bar doctor --fix",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
checks.push({
|
|
282
|
+
name: "Status data",
|
|
283
|
+
ok: options.dryRun ? true : isFile(paths.statusFile),
|
|
284
|
+
message: options.dryRun
|
|
285
|
+
? `[dry-run] would read ${paths.statusFile}`
|
|
286
|
+
: isFile(paths.statusFile)
|
|
287
|
+
? `found ${paths.statusFile}`
|
|
288
|
+
: `missing ${paths.statusFile}; run coding-usage-bar doctor --fix or coding-usage-bar daemon --once`,
|
|
251
289
|
});
|
|
252
290
|
return checks;
|
|
253
291
|
}
|
|
292
|
+
// One-command self-healing for the common "installed but the menu bar icon is
|
|
293
|
+
// gone" failures: missing SwiftBar, missing plugin, SwiftBar not running,
|
|
294
|
+
// SwiftBar missing from login items, and missing status data.
|
|
295
|
+
export async function runDoctorFix(options = {}) {
|
|
296
|
+
if (options.dryRun) {
|
|
297
|
+
return ["[dry-run] would repair SwiftBar, the menu bar plugin, login items, and status data automatically"];
|
|
298
|
+
}
|
|
299
|
+
const paths = buildPaths();
|
|
300
|
+
const probes = {
|
|
301
|
+
swiftBarInstalled: options.probes?.swiftBarInstalled ?? isSwiftBarInstalled,
|
|
302
|
+
pluginPresent: options.probes?.pluginPresent ?? (() => isFile(swiftBarPluginPath(paths).file)),
|
|
303
|
+
swiftBarRunning: options.probes?.swiftBarRunning ?? isSwiftBarRunning,
|
|
304
|
+
swiftBarInLoginItems: options.probes?.swiftBarInLoginItems ?? isSwiftBarInLoginItems,
|
|
305
|
+
statusFilePresent: options.probes?.statusFilePresent ?? (() => isFile(paths.statusFile)),
|
|
306
|
+
};
|
|
307
|
+
const effects = {
|
|
308
|
+
installSwiftBar: options.effects?.installSwiftBar ?? (() => ensureSwiftBarInstalled()),
|
|
309
|
+
installPlugin: options.effects?.installPlugin ?? (() => installMenuBar()),
|
|
310
|
+
launchSwiftBar: options.effects?.launchSwiftBar ?? (() => openSwiftBar()),
|
|
311
|
+
addLoginItem: options.effects?.addLoginItem ?? (() => addSwiftBarToLoginItems()),
|
|
312
|
+
collectStatus: options.effects?.collectStatus ?? (async () => runDaemonOnce()),
|
|
313
|
+
};
|
|
314
|
+
const messages = [];
|
|
315
|
+
let swiftBarInstalled = probes.swiftBarInstalled();
|
|
316
|
+
if (!swiftBarInstalled) {
|
|
317
|
+
messages.push(...effects.installSwiftBar());
|
|
318
|
+
swiftBarInstalled = probes.swiftBarInstalled();
|
|
319
|
+
}
|
|
320
|
+
if (!probes.pluginPresent()) {
|
|
321
|
+
messages.push(...effects.installPlugin());
|
|
322
|
+
}
|
|
323
|
+
if (swiftBarInstalled && !probes.swiftBarRunning()) {
|
|
324
|
+
messages.push(...effects.launchSwiftBar());
|
|
325
|
+
}
|
|
326
|
+
if (probes.swiftBarInLoginItems() === false) {
|
|
327
|
+
messages.push(...effects.addLoginItem());
|
|
328
|
+
}
|
|
329
|
+
if (!probes.statusFilePresent()) {
|
|
330
|
+
messages.push(...(await effects.collectStatus()));
|
|
331
|
+
}
|
|
332
|
+
return messages;
|
|
333
|
+
}
|
|
254
334
|
export function formatDoctor(checks) {
|
|
255
335
|
return checks
|
|
256
336
|
.map((check) => `${check.ok ? "OK" : "MISSING"} ${check.name}: ${check.message}`)
|
package/dist/glm.js
CHANGED
|
@@ -30,15 +30,11 @@ function resetTimeIso(value) {
|
|
|
30
30
|
// usage (same proto-style field omission as Kimi's zero `used`). The zero
|
|
31
31
|
// percentage is a real signal and must not be dropped; a missing reset time
|
|
32
32
|
// 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;
|
|
33
|
+
function pickTokenWindowsOfType(limits) {
|
|
34
|
+
let five = limits.find((limit) => limit.unit === 3 && limit.number === 5) ?? null;
|
|
35
|
+
let seven = limits.find((limit) => limit.unit === 6 && limit.number === 1) ?? null;
|
|
40
36
|
if (!five || !seven) {
|
|
41
|
-
const rest =
|
|
37
|
+
const rest = limits
|
|
42
38
|
.filter((limit) => limit !== five && limit !== seven)
|
|
43
39
|
.sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
|
|
44
40
|
five = five ?? rest.shift() ?? null;
|
|
@@ -46,6 +42,19 @@ function pickTokenWindows(limits) {
|
|
|
46
42
|
}
|
|
47
43
|
return five && seven ? { five, seven } : null;
|
|
48
44
|
}
|
|
45
|
+
function pickTokenWindows(limits) {
|
|
46
|
+
// Zhipu migrated Coding Plan quota reporting from TOKENS_LIMIT (token
|
|
47
|
+
// counts) to CREDIT_LIMIT (credit counts, observed live 2026-08-29) with the
|
|
48
|
+
// same unit/number window shapes. Prefer the token signal when both exist;
|
|
49
|
+
// fall back to credits so credit-based plans are not dropped as unusable.
|
|
50
|
+
for (const type of ["TOKENS_LIMIT", "CREDIT_LIMIT"]) {
|
|
51
|
+
const picked = pickTokenWindowsOfType(limits.filter((limit) => limit.type === type));
|
|
52
|
+
if (picked) {
|
|
53
|
+
return picked;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
49
58
|
export function usageFromGlmQuota(quota, options) {
|
|
50
59
|
if (!quota.success || !quota.data) {
|
|
51
60
|
return null;
|
package/dist/install.js
CHANGED
|
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
|
|
|
5
5
|
import { claudeStatusLineHasIngest, getClaudeStatusLineCommand, readClaudeSettings } from "./claude.js";
|
|
6
6
|
import { ensureConfig, readConfig } from "./config.js";
|
|
7
7
|
import { ensureDir, isFile, readJsonFile, writeJsonAtomic } from "./fs-util.js";
|
|
8
|
-
import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, openSwiftBar, uninstallMenuBar } from "./menubar.js";
|
|
8
|
+
import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, openSwiftBar, uninstallMenuBar, verifyMenuBarSetup } from "./menubar.js";
|
|
9
9
|
import { bakeGlyphAtlases } from "./glyphs.js";
|
|
10
10
|
import { buildPaths } from "./paths.js";
|
|
11
11
|
import { stableNodeExecutable } from "./node-runtime.js";
|
|
@@ -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 {
|
|
@@ -376,11 +380,13 @@ export function install(options = {}) {
|
|
|
376
380
|
if (dryRun) {
|
|
377
381
|
messages.push(`[dry-run] would write launchd plist: ${paths.launchAgentFile}`);
|
|
378
382
|
messages.push(`[dry-run] ProgramArguments: ${[...args, "daemon", "--once"].join(" ")}`);
|
|
383
|
+
messages.push(...verifyMenuBarSetup({ dryRun }));
|
|
379
384
|
return messages;
|
|
380
385
|
}
|
|
381
386
|
fs.writeFileSync(paths.launchAgentFile, xml, "utf8");
|
|
382
387
|
restartLaunchAgent(paths.launchAgentFile);
|
|
383
388
|
messages.push(`Installed and restarted launchd agent: ${paths.launchAgentFile}`);
|
|
389
|
+
messages.push(...verifyMenuBarSetup());
|
|
384
390
|
return messages;
|
|
385
391
|
}
|
|
386
392
|
export function uninstall(options = {}) {
|
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;
|
|
@@ -12,9 +16,24 @@ export declare function ensureSwiftBarInstalled(options?: {
|
|
|
12
16
|
}): string[];
|
|
13
17
|
export declare function isSwiftBarRunning(): boolean;
|
|
14
18
|
export declare function openSwiftBar(): string[];
|
|
19
|
+
export declare function isSwiftBarInLoginItems(): boolean | null;
|
|
15
20
|
export declare function addSwiftBarToLoginItems(options?: {
|
|
16
21
|
dryRun?: boolean;
|
|
17
22
|
}): string[];
|
|
23
|
+
export interface PluginRunResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
output: string;
|
|
26
|
+
}
|
|
27
|
+
export interface VerifyMenuBarOptions {
|
|
28
|
+
dryRun?: boolean;
|
|
29
|
+
runPlugin?: (file: string) => PluginRunResult;
|
|
30
|
+
loginItems?: () => boolean | null;
|
|
31
|
+
swiftBarInstalled?: () => boolean;
|
|
32
|
+
swiftBarRunning?: () => boolean;
|
|
33
|
+
repairPlugin?: () => string[];
|
|
34
|
+
launchSwiftBar?: () => string[];
|
|
35
|
+
}
|
|
36
|
+
export declare function verifyMenuBarSetup(options?: VerifyMenuBarOptions): string[];
|
|
18
37
|
export declare function renderMenuBar(snapshot?: StatusSnapshot, paths?: RuntimePaths, now?: Date): string;
|
|
19
38
|
export declare function swiftBarStatusItemVisibilityKeys(defaultsOutput: string): string[];
|
|
20
39
|
export declare function installMenuBar(options?: {
|
package/dist/menubar.js
CHANGED
|
@@ -120,27 +120,74 @@ function systemPrefersDark() {
|
|
|
120
120
|
function appCliPath(paths) {
|
|
121
121
|
return path.join(paths.stateDir, "app", "dist", "cli.js");
|
|
122
122
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
const TITLE_MODE_CYCLE = ["auto", "full", "single", "icon"];
|
|
124
|
+
const TITLE_TIER_LABEL = {
|
|
125
|
+
full: "Full",
|
|
126
|
+
single: "Single",
|
|
127
|
+
icon: "Icon",
|
|
128
|
+
};
|
|
129
|
+
// Measured on a notched 13" (2560x1664, 1470pt logical, 645pt right of the
|
|
130
|
+
// notch): the six-provider composite is 511pt wide including SwiftBar's item
|
|
131
|
+
// padding, a single-provider one is 68pt. The system's own extras (clock,
|
|
132
|
+
// Control Center, input menu, Spotlight) claim ~240pt before any third-party
|
|
133
|
+
// icon shows up, so `full` needs a budget well beyond its own width to be
|
|
134
|
+
// safe -- 645pt of notch-side bar cannot hold it even with nothing else
|
|
135
|
+
// installed.
|
|
136
|
+
const FULL_TIER_MIN_BUDGET_PT = 900;
|
|
137
|
+
const SINGLE_TIER_MIN_BUDGET_PT = 350;
|
|
138
|
+
// Name kept from the pre-tier boolean toggle: an existing empty marker file
|
|
139
|
+
// means the user had compact on, which is the icon tier now.
|
|
140
|
+
const TITLE_MODE_FILE = "compact-mode";
|
|
141
|
+
function titleModeFilePath(paths) {
|
|
142
|
+
return path.join(paths.stateDir, TITLE_MODE_FILE);
|
|
126
143
|
}
|
|
127
|
-
export function
|
|
144
|
+
export function readTitleMode(paths = buildPaths()) {
|
|
128
145
|
try {
|
|
129
|
-
|
|
146
|
+
const raw = fs.readFileSync(titleModeFilePath(paths), "utf8").trim();
|
|
147
|
+
if (raw === "") {
|
|
148
|
+
return "icon";
|
|
149
|
+
}
|
|
150
|
+
return TITLE_MODE_CYCLE.includes(raw) ? raw : "auto";
|
|
130
151
|
}
|
|
131
152
|
catch {
|
|
132
|
-
|
|
153
|
+
// Absent file (and any unreadable state) means hands off: let the measured
|
|
154
|
+
// budget decide.
|
|
155
|
+
return "auto";
|
|
133
156
|
}
|
|
134
157
|
}
|
|
135
|
-
export function
|
|
136
|
-
const filePath =
|
|
137
|
-
if (
|
|
158
|
+
export function writeTitleMode(mode, paths = buildPaths()) {
|
|
159
|
+
const filePath = titleModeFilePath(paths);
|
|
160
|
+
if (mode === "auto") {
|
|
138
161
|
fs.rmSync(filePath, { force: true });
|
|
139
|
-
return
|
|
162
|
+
return "auto";
|
|
140
163
|
}
|
|
141
164
|
ensureDir(paths.stateDir);
|
|
142
|
-
fs.writeFileSync(filePath,
|
|
143
|
-
return
|
|
165
|
+
fs.writeFileSync(filePath, `${mode}\n`);
|
|
166
|
+
return mode;
|
|
167
|
+
}
|
|
168
|
+
export function cycleTitleMode(paths = buildPaths()) {
|
|
169
|
+
const current = readTitleMode(paths);
|
|
170
|
+
const next = TITLE_MODE_CYCLE[(TITLE_MODE_CYCLE.indexOf(current) + 1) % TITLE_MODE_CYCLE.length];
|
|
171
|
+
return writeTitleMode(next, paths);
|
|
172
|
+
}
|
|
173
|
+
export function pickTitleTier(display, providerCount) {
|
|
174
|
+
if (providerCount === 0) {
|
|
175
|
+
return "icon";
|
|
176
|
+
}
|
|
177
|
+
const budget = display?.extrasBudgetPt;
|
|
178
|
+
if (typeof budget !== "number" || !Number.isFinite(budget)) {
|
|
179
|
+
// No measurement yet: fresh install, non-macOS host, or a failed probe.
|
|
180
|
+
// Assume the cramped case. Guessing `single` wrong costs detail; guessing
|
|
181
|
+
// `full` wrong costs the entire menu bar item.
|
|
182
|
+
return "single";
|
|
183
|
+
}
|
|
184
|
+
if (budget >= FULL_TIER_MIN_BUDGET_PT) {
|
|
185
|
+
return "full";
|
|
186
|
+
}
|
|
187
|
+
if (budget >= SINGLE_TIER_MIN_BUDGET_PT) {
|
|
188
|
+
return "single";
|
|
189
|
+
}
|
|
190
|
+
return "icon";
|
|
144
191
|
}
|
|
145
192
|
function configuredSwiftBarPluginDir(paths) {
|
|
146
193
|
// Isolation escape hatch: an explicit CODING_USAGE_BAR_PLUGIN_DIR always wins and never
|
|
@@ -268,22 +315,27 @@ export function openSwiftBar() {
|
|
|
268
315
|
}
|
|
269
316
|
return messages;
|
|
270
317
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (!appPath) {
|
|
275
|
-
return ["SwiftBar is not installed; skipping login item."];
|
|
276
|
-
}
|
|
318
|
+
// Whether SwiftBar auto-starts at login. Returns null when the login item list
|
|
319
|
+
// cannot be queried (Automation permission denied or non-interactive context).
|
|
320
|
+
export function isSwiftBarInLoginItems() {
|
|
277
321
|
try {
|
|
278
322
|
const result = execFileSync("osascript", [
|
|
279
323
|
"-e", 'tell application "System Events" to get the name of every login item',
|
|
280
324
|
], { encoding: "utf8" });
|
|
281
|
-
|
|
282
|
-
return ["SwiftBar already in login items."];
|
|
283
|
-
}
|
|
325
|
+
return result.split(",").map((name) => name.trim()).includes("SwiftBar");
|
|
284
326
|
}
|
|
285
327
|
catch {
|
|
286
|
-
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
export function addSwiftBarToLoginItems(options = {}) {
|
|
332
|
+
const dryRun = options.dryRun ?? false;
|
|
333
|
+
const appPath = swiftBarAppPath();
|
|
334
|
+
if (!appPath) {
|
|
335
|
+
return ["SwiftBar is not installed; skipping login item."];
|
|
336
|
+
}
|
|
337
|
+
if (isSwiftBarInLoginItems()) {
|
|
338
|
+
return ["SwiftBar already in login items."];
|
|
287
339
|
}
|
|
288
340
|
if (dryRun) {
|
|
289
341
|
return [`[dry-run] would add SwiftBar to login items: ${appPath}`];
|
|
@@ -298,6 +350,69 @@ export function addSwiftBarToLoginItems(options = {}) {
|
|
|
298
350
|
return [`Failed to add SwiftBar to login items: ${error instanceof Error ? error.message : String(error)}. Add manually in System Settings → General → Login Items.`];
|
|
299
351
|
}
|
|
300
352
|
}
|
|
353
|
+
// Execute the installed plugin exactly the way SwiftBar would and check it
|
|
354
|
+
// produces a real menu. The plugin prints "Usage ERR" with exit code 0 on
|
|
355
|
+
// failure, so a non-error status alone proves nothing.
|
|
356
|
+
function runPluginScript(file) {
|
|
357
|
+
try {
|
|
358
|
+
const stdout = execFileSync(file, { encoding: "utf8", timeout: 30000 });
|
|
359
|
+
const output = stdout.trim();
|
|
360
|
+
return { ok: output.length > 0 && !output.startsWith("Usage ERR"), output };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
return { ok: false, output: error instanceof Error ? error.message : String(error) };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const MENU_BAR_MANUAL_STEPS = [
|
|
367
|
+
"Menu bar is NOT ready yet. Fix it with these steps:",
|
|
368
|
+
" 1. If macOS is showing an Automation/Accessibility permission prompt, click Allow.",
|
|
369
|
+
" 2. Open SwiftBar: open -a SwiftBar",
|
|
370
|
+
" 3. If the icon is still missing, macOS may have folded it away: System Settings → Control Center → Menu Bar.",
|
|
371
|
+
" 4. Re-run: coding-usage-bar doctor --fix",
|
|
372
|
+
];
|
|
373
|
+
const LOGIN_ITEM_WARNING = [
|
|
374
|
+
"WARN SwiftBar is not in login items: the menu bar icon will disappear after a reboot.",
|
|
375
|
+
" If macOS showed a System Events Automation prompt, allow it and re-run coding-usage-bar install,",
|
|
376
|
+
" or add SwiftBar manually: System Settings → General → Login Items.",
|
|
377
|
+
];
|
|
378
|
+
// End-to-end acceptance at the end of install: the plugin exists, SwiftBar is
|
|
379
|
+
// running, and the plugin actually renders a menu. Auto-repairs once, then
|
|
380
|
+
// falls back to numbered manual steps instead of failing silently.
|
|
381
|
+
export function verifyMenuBarSetup(options = {}) {
|
|
382
|
+
if (options.dryRun) {
|
|
383
|
+
return ["[dry-run] would verify the SwiftBar plugin renders and the menu bar item appears"];
|
|
384
|
+
}
|
|
385
|
+
const paths = buildPaths();
|
|
386
|
+
const plugin = swiftBarPluginPath(paths);
|
|
387
|
+
const isInstalled = options.swiftBarInstalled ?? isSwiftBarInstalled;
|
|
388
|
+
const isRunning = options.swiftBarRunning ?? isSwiftBarRunning;
|
|
389
|
+
const messages = [];
|
|
390
|
+
if (!isInstalled()) {
|
|
391
|
+
return [
|
|
392
|
+
...MENU_BAR_MANUAL_STEPS,
|
|
393
|
+
` (SwiftBar is not installed. Install it with: brew install --cask swiftbar, then re-run coding-usage-bar install.)`,
|
|
394
|
+
];
|
|
395
|
+
}
|
|
396
|
+
if (!isFile(plugin.file)) {
|
|
397
|
+
messages.push(...(options.repairPlugin ?? installMenuBar)());
|
|
398
|
+
}
|
|
399
|
+
if (!isRunning()) {
|
|
400
|
+
messages.push(...(options.launchSwiftBar ?? openSwiftBar)());
|
|
401
|
+
}
|
|
402
|
+
if (!isFile(plugin.file)) {
|
|
403
|
+
return [...messages, ...MENU_BAR_MANUAL_STEPS, ` (Plugin file missing: ${plugin.file})`];
|
|
404
|
+
}
|
|
405
|
+
const run = (options.runPlugin ?? runPluginScript)(plugin.file);
|
|
406
|
+
if (!run.ok || !isRunning()) {
|
|
407
|
+
return [...messages, ...MENU_BAR_MANUAL_STEPS];
|
|
408
|
+
}
|
|
409
|
+
messages.push("");
|
|
410
|
+
messages.push("Menu bar ready — Coding Usage Bar should now appear at the top-right of your menu bar.");
|
|
411
|
+
if ((options.loginItems ?? isSwiftBarInLoginItems)() === false) {
|
|
412
|
+
messages.push(...LOGIN_ITEM_WARNING);
|
|
413
|
+
}
|
|
414
|
+
return messages;
|
|
415
|
+
}
|
|
301
416
|
function swiftBarEscape(value) {
|
|
302
417
|
return value.replaceAll("|", "\\|");
|
|
303
418
|
}
|
|
@@ -509,6 +624,56 @@ function titleIconParams() {
|
|
|
509
624
|
}
|
|
510
625
|
return { sfimage: "flame.fill", sfcolor: RAW_COLOR };
|
|
511
626
|
}
|
|
627
|
+
// Sort is stable per spec, so TITLE_PROVIDER_ORDER survives as the tie-break
|
|
628
|
+
// and the same snapshot always elects the same provider. Byte-stable output is
|
|
629
|
+
// what lets SwiftBar skip the menu rebuild.
|
|
630
|
+
function mostUrgentProvider(providers) {
|
|
631
|
+
return [...providers].sort((left, right) => (STATE_PRIORITY[left.analysis.state] - STATE_PRIORITY[right.analysis.state]))[0];
|
|
632
|
+
}
|
|
633
|
+
// The invariant this whole ladder exists for: every path out of here carries a
|
|
634
|
+
// visual. Missing assets, a PNG failure, an empty tier -- none of them may end
|
|
635
|
+
// with an item that renders as nothing, because "renders as nothing" is
|
|
636
|
+
// indistinguishable from "the tool is broken".
|
|
637
|
+
function titleLine(tier, providers, title, topState, prefersDark) {
|
|
638
|
+
const iconLine = () => line("", {
|
|
639
|
+
sfimage: "flame.fill",
|
|
640
|
+
sfcolor: STATE_COLOR[topState],
|
|
641
|
+
dropdown: false,
|
|
642
|
+
tooltip: title,
|
|
643
|
+
});
|
|
644
|
+
if (tier === "icon") {
|
|
645
|
+
return iconLine();
|
|
646
|
+
}
|
|
647
|
+
const urgent = tier === "single" ? mostUrgentProvider(providers) : undefined;
|
|
648
|
+
const shown = urgent ? [urgent] : providers;
|
|
649
|
+
const image = titleImageValue(shown, prefersDark);
|
|
650
|
+
if (image) {
|
|
651
|
+
return line("", {
|
|
652
|
+
image: image.image,
|
|
653
|
+
width: image.width,
|
|
654
|
+
height: image.height,
|
|
655
|
+
dropdown: false,
|
|
656
|
+
tooltip: title,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
const text = urgent ? titleSegment(urgent) : title;
|
|
660
|
+
return text
|
|
661
|
+
? line(text, { ...titleIconParams(), dropdown: false })
|
|
662
|
+
: iconLine();
|
|
663
|
+
}
|
|
664
|
+
function titleModeLabel(mode, autoTier) {
|
|
665
|
+
return mode === "auto"
|
|
666
|
+
? `Title Auto (${TITLE_TIER_LABEL[autoTier]})`
|
|
667
|
+
: `Title ${TITLE_TIER_LABEL[mode]}`;
|
|
668
|
+
}
|
|
669
|
+
// Carries the "why" behind a degraded title without spending a menu row on it.
|
|
670
|
+
function titleModeTooltip(display, autoTier) {
|
|
671
|
+
if (!display || typeof display.extrasBudgetPt !== "number") {
|
|
672
|
+
return `Menu bar space unknown, assuming ${TITLE_TIER_LABEL[autoTier]}. Click to cycle Auto/Full/Single/Icon.`;
|
|
673
|
+
}
|
|
674
|
+
const shape = display.hasNotch ? "notched display" : "full-width menu bar";
|
|
675
|
+
return `${display.extrasBudgetPt}pt of menu bar space (${shape}) fits ${TITLE_TIER_LABEL[autoTier]}. Click to cycle Auto/Full/Single/Icon.`;
|
|
676
|
+
}
|
|
512
677
|
function anyProviderStale(snapshot) {
|
|
513
678
|
return snapshot.providers.some((item) => item.meta.stale);
|
|
514
679
|
}
|
|
@@ -665,52 +830,35 @@ function usageCardValue(snapshot, providers, glyphs, now, prefersDark) {
|
|
|
665
830
|
// SwiftBar's content guard can skip the menu rebuild and menu bar repaint;
|
|
666
831
|
// never render anything derived from wall-clock age or countdowns here.
|
|
667
832
|
export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = buildPaths(), now = new Date()) {
|
|
668
|
-
const
|
|
833
|
+
const mode = readTitleMode(paths);
|
|
669
834
|
const providers = titleProviders(snapshot);
|
|
670
835
|
const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
|
|
671
|
-
const
|
|
836
|
+
const autoTier = pickTitleTier(snapshot.display, providers.length);
|
|
837
|
+
const tier = mode === "auto" ? autoTier : mode;
|
|
838
|
+
const modeLine = line(titleModeLabel(mode, autoTier), {
|
|
672
839
|
bash: stableNodeExecutable(),
|
|
673
840
|
param1: appCliPath(paths),
|
|
674
841
|
param2: "menubar",
|
|
675
|
-
param3: "
|
|
842
|
+
param3: "cycle-title-mode",
|
|
676
843
|
terminal: false,
|
|
677
844
|
refresh: true,
|
|
678
845
|
color: TEXT_COLOR,
|
|
679
|
-
sfimage:
|
|
680
|
-
|
|
846
|
+
sfimage: "arrow.triangle.2.circlepath",
|
|
847
|
+
tooltip: titleModeTooltip(snapshot.display, autoTier),
|
|
848
|
+
});
|
|
681
849
|
if (!title) {
|
|
682
850
|
return [
|
|
683
851
|
line("Coding Usage Bar No Usage", { sfimage: "flame.fill", sfcolor: RAW_COLOR }),
|
|
684
852
|
"---",
|
|
685
853
|
line("No provider usage available", { color: MUTED_COLOR }),
|
|
686
|
-
|
|
854
|
+
modeLine,
|
|
687
855
|
line("Refresh now", { refresh: true, color: TEXT_COLOR, sfimage: "arrow.clockwise" }),
|
|
688
856
|
].join("\n");
|
|
689
857
|
}
|
|
690
|
-
const topState =
|
|
691
|
-
?? "RAW";
|
|
858
|
+
const topState = mostUrgentProvider(providers)?.analysis.state ?? "RAW";
|
|
692
859
|
const prefersDark = systemPrefersDark();
|
|
693
|
-
const titleImage = titleImageValue(providers, prefersDark);
|
|
694
860
|
const lines = [
|
|
695
|
-
|
|
696
|
-
? line("", {
|
|
697
|
-
sfimage: "flame.fill",
|
|
698
|
-
sfcolor: STATE_COLOR[topState],
|
|
699
|
-
dropdown: false,
|
|
700
|
-
tooltip: title,
|
|
701
|
-
})
|
|
702
|
-
: titleImage
|
|
703
|
-
? line("", {
|
|
704
|
-
image: titleImage.image,
|
|
705
|
-
width: titleImage.width,
|
|
706
|
-
height: titleImage.height,
|
|
707
|
-
dropdown: false,
|
|
708
|
-
tooltip: title,
|
|
709
|
-
})
|
|
710
|
-
: line(title, {
|
|
711
|
-
...titleIconParams(),
|
|
712
|
-
dropdown: false,
|
|
713
|
-
}),
|
|
861
|
+
titleLine(tier, providers, title, topState, prefersDark),
|
|
714
862
|
"---",
|
|
715
863
|
];
|
|
716
864
|
const glyphs = glyphSetFor(paths);
|
|
@@ -798,7 +946,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
798
946
|
if (visibleIssues.length > 0) {
|
|
799
947
|
lines.push("---");
|
|
800
948
|
}
|
|
801
|
-
lines.push(
|
|
949
|
+
lines.push(modeLine);
|
|
802
950
|
lines.push(line("Refresh now", {
|
|
803
951
|
refresh: true,
|
|
804
952
|
color: TEXT_COLOR,
|
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
|
@@ -96,11 +96,18 @@ export interface StatusIssue {
|
|
|
96
96
|
code: string;
|
|
97
97
|
message: string;
|
|
98
98
|
}
|
|
99
|
+
export interface DisplayGeometry {
|
|
100
|
+
screenWidthPt: number;
|
|
101
|
+
hasNotch: boolean;
|
|
102
|
+
extrasBudgetPt: number;
|
|
103
|
+
measuredAt: string;
|
|
104
|
+
}
|
|
99
105
|
export interface StatusSnapshot {
|
|
100
106
|
generatedAt: string;
|
|
101
107
|
profile: BurnProfile;
|
|
102
108
|
providers: ProviderStatus[];
|
|
103
109
|
issues: StatusIssue[];
|
|
110
|
+
display?: DisplayGeometry | null;
|
|
104
111
|
}
|
|
105
112
|
export interface BurnConfig {
|
|
106
113
|
providers: ProviderId[];
|
package/docs/assets/demo.gif
CHANGED
|
Binary file
|
package/docs/assets/hero.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coding-usage-bar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "macOS menu bar usage monitor for Claude Code, Codex, GLM, DeepSeek, and MiniMax.",
|
|
5
5
|
"author": "hanzhangzzz",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"type": "module",
|
|
31
31
|
"bin": {
|
|
32
|
-
"coding-usage-bar": "
|
|
32
|
+
"coding-usage-bar": "dist/cli.js"
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"assets",
|
|
Binary file
|
|
Binary file
|