coding-usage-bar 0.5.0 → 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 +25 -1
- package/dist/cli.js +7 -5
- 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/glm.js +17 -8
- package/dist/install.js +5 -1
- package/dist/menubar.d.ts +6 -2
- package/dist/menubar.js +121 -41
- package/dist/runtime.d.ts +2 -1
- package/dist/runtime.js +6 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,12 @@ coding-usage-bar status
|
|
|
35
35
|
|
|
36
36
|
## Troubleshooting
|
|
37
37
|
|
|
38
|
+
**Menu bar icon showing on your external display but not on the laptop?**
|
|
39
|
+
|
|
40
|
+
That is a width problem, not a bug. macOS does not clip a menu bar item that is too wide -- it pushes it left, under the notch and into the app menu area, where it is simply gone. A notched 13" leaves 645pt to the right of the notch for *every* menu bar icon on the machine, and the full six-provider title alone is 511pt.
|
|
41
|
+
|
|
42
|
+
Coding Usage Bar measures that space and picks a title width to fit (see [Menu Bar](#menu-bar)). Open the dropdown and read the `Title` row: its tooltip tells you how much space was measured and which width was chosen. Click the row to override.
|
|
43
|
+
|
|
38
44
|
**Menu bar icon not showing after install?**
|
|
39
45
|
|
|
40
46
|
1. Run `coding-usage-bar doctor --fix`. It relaunches SwiftBar, reinstalls the menu bar plugin if missing, re-adds SwiftBar to login items, and collects status data.
|
|
@@ -55,6 +61,7 @@ coding-usage-bar status
|
|
|
55
61
|
| `coding-usage-bar status --json` | Print the same status snapshot written to `~/.coding-usage-bar/status.json` |
|
|
56
62
|
| `coding-usage-bar status --refresh` | Re-collect local usage before printing status |
|
|
57
63
|
| `coding-usage-bar menubar render` | Print SwiftBar-compatible menu text from `~/.coding-usage-bar/status.json` |
|
|
64
|
+
| `coding-usage-bar menubar cycle-title-mode` | Cycle the menu bar title width: `auto` → `full` → `single` → `icon` |
|
|
58
65
|
| `coding-usage-bar menubar install` | Install the SwiftBar plugin wrapper |
|
|
59
66
|
| `coding-usage-bar menubar uninstall` | Remove the Coding Usage Bar managed SwiftBar plugin |
|
|
60
67
|
| `coding-usage-bar ingest claude-statusline` | Read Claude Code status line JSON from stdin and cache usage |
|
|
@@ -193,6 +200,22 @@ The compact menu bar title and dropdown use recognizable Provider marks for Code
|
|
|
193
200
|
{Codex icon} 5H:14%,7D:67% │ {Claude icon} 5H:24%,7D:74% │ {GLM icon} 5H:36%,7D:7%
|
|
194
201
|
```
|
|
195
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
|
+
|
|
196
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.
|
|
197
220
|
|
|
198
221
|
## Runtime Files
|
|
@@ -201,7 +224,8 @@ SwiftBar only supports one bitmap image on a single stable title item, so Coding
|
|
|
201
224
|
|------|---------|
|
|
202
225
|
| `~/.coding-usage-bar/app/` | Stable runtime copy used by launchd, Claude ingest hints, and SwiftBar |
|
|
203
226
|
| `~/.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 |
|
|
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` |
|
|
205
229
|
| `~/.coding-usage-bar/codex/latest.json` | Latest normalized Codex usage |
|
|
206
230
|
| `~/.coding-usage-bar/claude/latest.json` | Latest normalized Claude usage after status line ingest |
|
|
207
231
|
| `~/.coding-usage-bar/glm/latest.json` | Latest normalized GLM usage from Zhipu AI quota API |
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import { formatStatusRows, formatAnalysisDetail, formatIssues, formatProviderMet
|
|
|
6
6
|
import { loadDisplayStatusSnapshot, collectStatusSnapshot } from "./runtime.js";
|
|
7
7
|
import { runDaemonOnce } from "./daemon.js";
|
|
8
8
|
import { doctorHasFailures, formatDoctor, runDoctor, runDoctorFix } from "./doctor.js";
|
|
9
|
-
import { installMenuBar, renderMenuBar, uninstallMenuBar,
|
|
9
|
+
import { installMenuBar, renderMenuBar, uninstallMenuBar, cycleTitleMode } from "./menubar.js";
|
|
10
10
|
import { buildPaths } from "./paths.js";
|
|
11
11
|
import { bakeGlyphAtlases } from "./glyphs.js";
|
|
12
12
|
import { maybePromptForStar } from "./star.js";
|
|
@@ -25,7 +25,7 @@ Usage:
|
|
|
25
25
|
coding-usage-bar menubar render
|
|
26
26
|
coding-usage-bar menubar install [--dry-run]
|
|
27
27
|
coding-usage-bar menubar uninstall [--dry-run]
|
|
28
|
-
coding-usage-bar menubar
|
|
28
|
+
coding-usage-bar menubar cycle-title-mode
|
|
29
29
|
coding-usage-bar menubar bake-glyphs [--dry-run]
|
|
30
30
|
coding-usage-bar ingest claude-statusline
|
|
31
31
|
coding-usage-bar daemon [--once] [--dry-run]
|
|
@@ -118,9 +118,11 @@ async function main() {
|
|
|
118
118
|
console.log(uninstallMenuBar({ dryRun }).join("\n"));
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// `toggle-compact` is the pre-tier name for this action. A dropdown that
|
|
122
|
+
// SwiftBar rendered before the upgrade still carries it, so a click on
|
|
123
|
+
// that stale menu must keep working instead of erroring out.
|
|
124
|
+
if (command === "menubar" && (subcommand === "cycle-title-mode" || subcommand === "toggle-compact")) {
|
|
125
|
+
console.log(cycleTitleMode());
|
|
124
126
|
return;
|
|
125
127
|
}
|
|
126
128
|
if (command === "menubar" && subcommand === "bake-glyphs") {
|
package/dist/codex.d.ts
CHANGED
|
@@ -3,4 +3,6 @@ export declare function usageFromCodexRateLimits(rateLimits: unknown, options: {
|
|
|
3
3
|
observedAt?: string;
|
|
4
4
|
source: string;
|
|
5
5
|
}): ProviderUsage | null;
|
|
6
|
-
export declare function collectCodexUsage(codexHome?: string
|
|
6
|
+
export declare function collectCodexUsage(codexHome?: string, options?: {
|
|
7
|
+
tailChunkBytes?: number;
|
|
8
|
+
}): ProviderUsage;
|
package/dist/codex.js
CHANGED
|
@@ -72,34 +72,71 @@ export function usageFromCodexRateLimits(rateLimits, options) {
|
|
|
72
72
|
windows,
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
const DEFAULT_TAIL_CHUNK_BYTES = 1024 * 1024;
|
|
76
|
+
function candidateFromLine(line, file, fallbackMs) {
|
|
77
|
+
if (!line || !line.includes("rate_limits")) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(line);
|
|
82
|
+
const payload = parsed.payload;
|
|
83
|
+
const rateLimits = payload?.rate_limits ?? parsed.rate_limits;
|
|
84
|
+
const observedMs = parseObservedMs(parsed.timestamp ?? parsed.ts, fallbackMs);
|
|
85
|
+
const usage = usageFromCodexRateLimits(rateLimits, {
|
|
86
|
+
source: file,
|
|
87
|
+
observedAt: new Date(observedMs).toISOString(),
|
|
88
|
+
});
|
|
89
|
+
return usage ? { usage, observedMs } : null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Session rollouts of a long-lived Codex session can exceed V8's max string
|
|
96
|
+
// length (~512 MiB); readFileSync(file, "utf8") then throws and the newest
|
|
97
|
+
// usage silently disappears. Read the file backwards in bounded chunks and stop
|
|
98
|
+
// at the first complete line that carries rate_limits.
|
|
99
|
+
function latestCandidateFromFile(file, chunkBytes) {
|
|
76
100
|
const stat = fs.statSync(file);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
const fd = fs.openSync(file, "r");
|
|
102
|
+
try {
|
|
103
|
+
let position = stat.size;
|
|
104
|
+
let carry = Buffer.alloc(0);
|
|
105
|
+
while (position > 0) {
|
|
106
|
+
const readSize = Math.min(chunkBytes, position);
|
|
107
|
+
position -= readSize;
|
|
108
|
+
const chunk = Buffer.alloc(readSize);
|
|
109
|
+
fs.readSync(fd, chunk, 0, readSize, position);
|
|
110
|
+
const combined = carry.length > 0 ? Buffer.concat([chunk, carry]) : chunk;
|
|
111
|
+
let lineStart = 0;
|
|
112
|
+
if (position > 0) {
|
|
113
|
+
const newline = combined.indexOf(0x0a);
|
|
114
|
+
if (newline === -1) {
|
|
115
|
+
carry = combined;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
carry = combined.subarray(0, newline);
|
|
119
|
+
lineStart = newline + 1;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
carry = Buffer.alloc(0);
|
|
123
|
+
}
|
|
124
|
+
const lines = combined.subarray(lineStart).toString("utf8").split(/\r?\n/);
|
|
125
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
126
|
+
const candidate = candidateFromLine(lines[i]?.trim() ?? "", file, stat.mtimeMs);
|
|
127
|
+
if (candidate) {
|
|
128
|
+
return candidate;
|
|
129
|
+
}
|
|
94
130
|
}
|
|
95
131
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
fs.closeSync(fd);
|
|
99
135
|
}
|
|
100
136
|
return null;
|
|
101
137
|
}
|
|
102
|
-
export function collectCodexUsage(codexHome = path.join(process.env.HOME ?? "", ".codex")) {
|
|
138
|
+
export function collectCodexUsage(codexHome = path.join(process.env.HOME ?? "", ".codex"), options = {}) {
|
|
139
|
+
const chunkBytes = options.tailChunkBytes ?? DEFAULT_TAIL_CHUNK_BYTES;
|
|
103
140
|
if (!isDir(codexHome)) {
|
|
104
141
|
throw new Error(`Codex usage unavailable: ${codexHome} does not exist`);
|
|
105
142
|
}
|
|
@@ -112,7 +149,7 @@ export function collectCodexUsage(codexHome = path.join(process.env.HOME ?? "",
|
|
|
112
149
|
break;
|
|
113
150
|
}
|
|
114
151
|
try {
|
|
115
|
-
const candidate = latestCandidateFromFile(file);
|
|
152
|
+
const candidate = latestCandidateFromFile(file, chunkBytes);
|
|
116
153
|
if (candidate && (!latest || candidate.observedMs > latest.observedMs)) {
|
|
117
154
|
latest = candidate;
|
|
118
155
|
}
|
|
@@ -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/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
|
@@ -170,7 +170,11 @@ function restartLaunchAgent(plistFile) {
|
|
|
170
170
|
}
|
|
171
171
|
try {
|
|
172
172
|
execFileSync("launchctl", ["bootstrap", domain, plistFile]);
|
|
173
|
-
|
|
173
|
+
// No -k: bootout above already SIGTERMed any old instance, so the kill arm of
|
|
174
|
+
// "kickstart -k" can never fire here — and on macOS 26 (Tahoe) a SIGKILL from
|
|
175
|
+
// kickstart -k can wedge the job in "pended nondemand spawn", silently killing
|
|
176
|
+
// all automatic scheduling for it.
|
|
177
|
+
execFileSync("launchctl", ["kickstart", `${domain}/${LAUNCHD_LABEL}`]);
|
|
174
178
|
return;
|
|
175
179
|
}
|
|
176
180
|
catch {
|
package/dist/menubar.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { RuntimePaths, StatusSnapshot } from "./types.js";
|
|
2
2
|
export declare function resetGlyphSetCache(): void;
|
|
3
|
-
export
|
|
4
|
-
export
|
|
3
|
+
export type TitleTier = "full" | "single" | "icon";
|
|
4
|
+
export type TitleMode = "auto" | TitleTier;
|
|
5
|
+
export declare function readTitleMode(paths?: RuntimePaths): TitleMode;
|
|
6
|
+
export declare function writeTitleMode(mode: TitleMode, paths?: RuntimePaths): TitleMode;
|
|
7
|
+
export declare function cycleTitleMode(paths?: RuntimePaths): TitleMode;
|
|
8
|
+
export declare function pickTitleTier(display: StatusSnapshot["display"], providerCount: number): TitleTier;
|
|
5
9
|
export declare function swiftBarPluginPath(paths?: RuntimePaths): {
|
|
6
10
|
dir: string;
|
|
7
11
|
file: string;
|
package/dist/menubar.js
CHANGED
|
@@ -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
|
|
@@ -577,6 +624,56 @@ function titleIconParams() {
|
|
|
577
624
|
}
|
|
578
625
|
return { sfimage: "flame.fill", sfcolor: RAW_COLOR };
|
|
579
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
|
+
}
|
|
580
677
|
function anyProviderStale(snapshot) {
|
|
581
678
|
return snapshot.providers.some((item) => item.meta.stale);
|
|
582
679
|
}
|
|
@@ -733,52 +830,35 @@ function usageCardValue(snapshot, providers, glyphs, now, prefersDark) {
|
|
|
733
830
|
// SwiftBar's content guard can skip the menu rebuild and menu bar repaint;
|
|
734
831
|
// never render anything derived from wall-clock age or countdowns here.
|
|
735
832
|
export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = buildPaths(), now = new Date()) {
|
|
736
|
-
const
|
|
833
|
+
const mode = readTitleMode(paths);
|
|
737
834
|
const providers = titleProviders(snapshot);
|
|
738
835
|
const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
|
|
739
|
-
const
|
|
836
|
+
const autoTier = pickTitleTier(snapshot.display, providers.length);
|
|
837
|
+
const tier = mode === "auto" ? autoTier : mode;
|
|
838
|
+
const modeLine = line(titleModeLabel(mode, autoTier), {
|
|
740
839
|
bash: stableNodeExecutable(),
|
|
741
840
|
param1: appCliPath(paths),
|
|
742
841
|
param2: "menubar",
|
|
743
|
-
param3: "
|
|
842
|
+
param3: "cycle-title-mode",
|
|
744
843
|
terminal: false,
|
|
745
844
|
refresh: true,
|
|
746
845
|
color: TEXT_COLOR,
|
|
747
|
-
sfimage:
|
|
748
|
-
|
|
846
|
+
sfimage: "arrow.triangle.2.circlepath",
|
|
847
|
+
tooltip: titleModeTooltip(snapshot.display, autoTier),
|
|
848
|
+
});
|
|
749
849
|
if (!title) {
|
|
750
850
|
return [
|
|
751
851
|
line("Coding Usage Bar No Usage", { sfimage: "flame.fill", sfcolor: RAW_COLOR }),
|
|
752
852
|
"---",
|
|
753
853
|
line("No provider usage available", { color: MUTED_COLOR }),
|
|
754
|
-
|
|
854
|
+
modeLine,
|
|
755
855
|
line("Refresh now", { refresh: true, color: TEXT_COLOR, sfimage: "arrow.clockwise" }),
|
|
756
856
|
].join("\n");
|
|
757
857
|
}
|
|
758
|
-
const topState =
|
|
759
|
-
?? "RAW";
|
|
858
|
+
const topState = mostUrgentProvider(providers)?.analysis.state ?? "RAW";
|
|
760
859
|
const prefersDark = systemPrefersDark();
|
|
761
|
-
const titleImage = titleImageValue(providers, prefersDark);
|
|
762
860
|
const lines = [
|
|
763
|
-
|
|
764
|
-
? line("", {
|
|
765
|
-
sfimage: "flame.fill",
|
|
766
|
-
sfcolor: STATE_COLOR[topState],
|
|
767
|
-
dropdown: false,
|
|
768
|
-
tooltip: title,
|
|
769
|
-
})
|
|
770
|
-
: titleImage
|
|
771
|
-
? line("", {
|
|
772
|
-
image: titleImage.image,
|
|
773
|
-
width: titleImage.width,
|
|
774
|
-
height: titleImage.height,
|
|
775
|
-
dropdown: false,
|
|
776
|
-
tooltip: title,
|
|
777
|
-
})
|
|
778
|
-
: line(title, {
|
|
779
|
-
...titleIconParams(),
|
|
780
|
-
dropdown: false,
|
|
781
|
-
}),
|
|
861
|
+
titleLine(tier, providers, title, topState, prefersDark),
|
|
782
862
|
"---",
|
|
783
863
|
];
|
|
784
864
|
const glyphs = glyphSetFor(paths);
|
|
@@ -866,7 +946,7 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
866
946
|
if (visibleIssues.length > 0) {
|
|
867
947
|
lines.push("---");
|
|
868
948
|
}
|
|
869
|
-
lines.push(
|
|
949
|
+
lines.push(modeLine);
|
|
870
950
|
lines.push(line("Refresh now", {
|
|
871
951
|
refresh: true,
|
|
872
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[];
|