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 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, 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
  }
@@ -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 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;
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 = tokenLimits
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
- 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
@@ -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 COMPACT_MODE_FILE = "compact-mode";
124
- function compactModeFilePath(paths) {
125
- return path.join(paths.stateDir, COMPACT_MODE_FILE);
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 readCompactMode(paths = buildPaths()) {
144
+ export function readTitleMode(paths = buildPaths()) {
128
145
  try {
129
- return fs.existsSync(compactModeFilePath(paths));
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
- return false;
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 toggleCompactMode(paths = buildPaths()) {
136
- const filePath = compactModeFilePath(paths);
137
- if (fs.existsSync(filePath)) {
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 false;
162
+ return "auto";
140
163
  }
141
164
  ensureDir(paths.stateDir);
142
- fs.writeFileSync(filePath, "");
143
- return true;
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 compact = readCompactMode(paths);
833
+ const mode = readTitleMode(paths);
737
834
  const providers = titleProviders(snapshot);
738
835
  const title = providers.map(titleSegment).join(` ${TITLE_SEPARATOR} `);
739
- const toggleParams = {
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: "toggle-compact",
842
+ param3: "cycle-title-mode",
744
843
  terminal: false,
745
844
  refresh: true,
746
845
  color: TEXT_COLOR,
747
- sfimage: compact ? "rectangle.expand.vertical" : "rectangle.compress.vertical",
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
- line(compact ? "Expand" : "Collapse", toggleParams),
854
+ modeLine,
755
855
  line("Refresh now", { refresh: true, color: TEXT_COLOR, sfimage: "arrow.clockwise" }),
756
856
  ].join("\n");
757
857
  }
758
- const topState = [...providers].sort((left, right) => (STATE_PRIORITY[left.analysis.state] - STATE_PRIORITY[right.analysis.state]))[0]?.analysis.state
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
- 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
- }),
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(line(compact ? "Expand" : "Collapse", toggleParams));
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[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-usage-bar",
3
- "version": "0.5.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",