coding-usage-bar 0.3.1 → 0.4.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/dist/burn.d.ts +1 -0
- package/dist/burn.js +8 -2
- package/dist/cli.js +14 -0
- package/dist/daemon.js +4 -1
- package/dist/glm.d.ts +1 -1
- package/dist/glm.js +39 -9
- package/dist/glyphs.d.ts +68 -0
- package/dist/glyphs.js +176 -0
- package/dist/install.js +19 -0
- package/dist/menubar-card.d.ts +30 -0
- package/dist/menubar-card.js +189 -0
- package/dist/menubar-render.d.ts +19 -0
- package/dist/menubar-render.js +46 -10
- package/dist/menubar.d.ts +1 -0
- package/dist/menubar.js +250 -104
- package/dist/notifier.d.ts +1 -0
- package/dist/notifier.js +45 -14
- package/dist/paths.js +1 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +29 -4
- package/dist/status.d.ts +4 -0
- package/dist/status.js +122 -3
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/menubar-render.d.ts
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
import { DecodedPNG } from "./png.js";
|
|
2
|
+
export interface Rgb {
|
|
3
|
+
r: number;
|
|
4
|
+
g: number;
|
|
5
|
+
b: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function parseHex(hex: string): Rgb;
|
|
8
|
+
export declare class Canvas {
|
|
9
|
+
readonly width: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
private readonly data;
|
|
12
|
+
constructor(width: number, height: number);
|
|
13
|
+
private composite;
|
|
14
|
+
fillRoundedRect(ax: number, ay: number, aw: number, ah: number, radius: number, color: Rgb, alpha: number): void;
|
|
15
|
+
drawGlyphCell(atlas: DecodedPNG, srcX: number, srcW: number, ax: number, ay: number, color: Rgb, alpha: number): void;
|
|
16
|
+
drawIcon(icon: DecodedPNG, ax: number, ay: number, size: number): void;
|
|
17
|
+
toPNGBase64(): string;
|
|
18
|
+
}
|
|
19
|
+
export declare function loadIcon(iconPath: string): DecodedPNG | null;
|
|
1
20
|
export interface TitleSegment {
|
|
2
21
|
provider: string;
|
|
3
22
|
bars: Array<{
|
package/dist/menubar-render.js
CHANGED
|
@@ -8,7 +8,7 @@ import { encodePNG, decodePNG } from "./png.js";
|
|
|
8
8
|
// (bottom-left origin) coordinate system mirror the old AppKit code exactly so
|
|
9
9
|
// the rendered images stay visually identical.
|
|
10
10
|
const SUPERSAMPLE = 4; // 4x4 coverage sampling for anti-aliased rounded corners
|
|
11
|
-
function parseHex(hex) {
|
|
11
|
+
export function parseHex(hex) {
|
|
12
12
|
const v = hex.replace("#", "");
|
|
13
13
|
return {
|
|
14
14
|
r: parseInt(v.slice(0, 2), 16) / 255,
|
|
@@ -16,7 +16,7 @@ function parseHex(hex) {
|
|
|
16
16
|
b: parseInt(v.slice(4, 6), 16) / 255,
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
class Canvas {
|
|
19
|
+
export class Canvas {
|
|
20
20
|
width;
|
|
21
21
|
height;
|
|
22
22
|
data; // straight-alpha RGBA, 0..1
|
|
@@ -59,20 +59,31 @@ class Canvas {
|
|
|
59
59
|
const pxMax = Math.min(this.width, Math.ceil(ax + aw));
|
|
60
60
|
const pyMin = Math.max(0, Math.floor(H - (ay + ah)));
|
|
61
61
|
const pyMax = Math.min(H, Math.ceil(H - ay));
|
|
62
|
+
const sdf = (sx, sy) => {
|
|
63
|
+
const qx = Math.abs(sx - cx) - halfW;
|
|
64
|
+
const qy = Math.abs(sy - cy) - halfH;
|
|
65
|
+
const dxo = Math.max(qx, 0);
|
|
66
|
+
const dyo = Math.max(qy, 0);
|
|
67
|
+
return Math.sqrt(dxo * dxo + dyo * dyo) + Math.min(Math.max(qx, qy), 0) - r;
|
|
68
|
+
};
|
|
62
69
|
for (let py = pyMin; py < pyMax; py++) {
|
|
63
70
|
for (let px = pxMin; px < pxMax; px++) {
|
|
71
|
+
// Fast path keeps large fills (the full card background) cheap:
|
|
72
|
+
// supersample only within one pixel of the shape edge.
|
|
73
|
+
const center = sdf(px + 0.5, H - (py + 0.5));
|
|
74
|
+
if (center <= -0.71) {
|
|
75
|
+
this.composite(px, py, color, alpha);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (center >= 0.71) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
64
81
|
let hits = 0;
|
|
65
82
|
for (let j = 0; j < SUPERSAMPLE; j++) {
|
|
66
83
|
for (let i = 0; i < SUPERSAMPLE; i++) {
|
|
67
84
|
const sx = px + (i + 0.5) / SUPERSAMPLE;
|
|
68
85
|
const sPngY = py + (j + 0.5) / SUPERSAMPLE;
|
|
69
|
-
|
|
70
|
-
const qx = Math.abs(sx - cx) - halfW;
|
|
71
|
-
const qy = Math.abs(sy - cy) - halfH;
|
|
72
|
-
const dxo = Math.max(qx, 0);
|
|
73
|
-
const dyo = Math.max(qy, 0);
|
|
74
|
-
const dist = Math.sqrt(dxo * dxo + dyo * dyo) + Math.min(Math.max(qx, qy), 0) - r;
|
|
75
|
-
if (dist <= 0)
|
|
86
|
+
if (sdf(sx, H - sPngY) <= 0)
|
|
76
87
|
hits++;
|
|
77
88
|
}
|
|
78
89
|
}
|
|
@@ -82,6 +93,31 @@ class Canvas {
|
|
|
82
93
|
}
|
|
83
94
|
}
|
|
84
95
|
}
|
|
96
|
+
// Blit one glyph cell from a baked atlas 1:1, tinting the (white, alpha
|
|
97
|
+
// anti-aliased) glyph pixels with `color`. AppKit coords: `ax`/`ay` place
|
|
98
|
+
// the cell's bottom-left corner.
|
|
99
|
+
drawGlyphCell(atlas, srcX, srcW, ax, ay, color, alpha) {
|
|
100
|
+
const H = this.height;
|
|
101
|
+
const cellH = atlas.height;
|
|
102
|
+
const x0 = Math.round(ax);
|
|
103
|
+
const y0 = Math.round(ay);
|
|
104
|
+
for (let sy = 0; sy < cellH; sy++) {
|
|
105
|
+
// Atlas rows are top-down; canvas rows are addressed top-down too, but
|
|
106
|
+
// the destination rect is expressed y-up from `ay`.
|
|
107
|
+
const py = H - (y0 + cellH) + sy;
|
|
108
|
+
if (py < 0 || py >= H)
|
|
109
|
+
continue;
|
|
110
|
+
for (let sx = 0; sx < srcW; sx++) {
|
|
111
|
+
const px = x0 + sx;
|
|
112
|
+
if (px < 0 || px >= this.width)
|
|
113
|
+
continue;
|
|
114
|
+
const a = atlas.data[(sy * atlas.width + (srcX + sx)) * 4 + 3] / 255;
|
|
115
|
+
if (a > 0) {
|
|
116
|
+
this.composite(px, py, color, a * alpha);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
85
121
|
// Draw an RGBA source image upright into an AppKit-space rect (bottom-left
|
|
86
122
|
// origin), bilinearly scaled to `size` x `size`. Mirrors NSImage.drawInRect.
|
|
87
123
|
drawIcon(icon, ax, ay, size) {
|
|
@@ -150,7 +186,7 @@ function bilinear(data, w, h, fx, fy) {
|
|
|
150
186
|
};
|
|
151
187
|
}
|
|
152
188
|
const iconCache = new Map();
|
|
153
|
-
function loadIcon(iconPath) {
|
|
189
|
+
export function loadIcon(iconPath) {
|
|
154
190
|
if (iconCache.has(iconPath)) {
|
|
155
191
|
return iconCache.get(iconPath) ?? null;
|
|
156
192
|
}
|
package/dist/menubar.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RuntimePaths, StatusSnapshot } from "./types.js";
|
|
2
|
+
export declare function resetGlyphSetCache(): void;
|
|
2
3
|
export declare function readCompactMode(paths?: RuntimePaths): boolean;
|
|
3
4
|
export declare function toggleCompactMode(paths?: RuntimePaths): boolean;
|
|
4
5
|
export declare function swiftBarPluginPath(paths?: RuntimePaths): {
|
package/dist/menubar.js
CHANGED
|
@@ -7,7 +7,9 @@ import { formatProviderLabel, formatResetTime } from "./format.js";
|
|
|
7
7
|
import { buildPaths } from "./paths.js";
|
|
8
8
|
import { stableNodeExecutable } from "./node-runtime.js";
|
|
9
9
|
import { loadDisplayStatusSnapshot } from "./runtime.js";
|
|
10
|
-
import { renderTitleImage
|
|
10
|
+
import { renderTitleImage } from "./menubar-render.js";
|
|
11
|
+
import { loadGlyphSet } from "./glyphs.js";
|
|
12
|
+
import { renderUsageCard } from "./menubar-card.js";
|
|
11
13
|
const MARKER = "coding-usage-bar managed SwiftBar plugin";
|
|
12
14
|
const SWIFTBAR_APP_PATHS = ["/Applications/SwiftBar.app", path.join(process.env.HOME ?? "", "Applications", "SwiftBar.app")];
|
|
13
15
|
const STATE_LABEL = {
|
|
@@ -66,33 +68,53 @@ const ASSET_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
|
|
|
66
68
|
const imageCache = new Map();
|
|
67
69
|
const TITLE_IMAGE_SCALE = 2;
|
|
68
70
|
const titleImageCache = new Map();
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
// The dropdown card is the ONLY image in the dropdown. Never go back to
|
|
72
|
+
// per-row images: SwiftBar rebuilds the whole menu on any output change, and
|
|
73
|
+
// its cost scales with the number of image-bearing menu items (17 images =
|
|
74
|
+
// 6-8s WindowServer storm per data refresh; title + one card = <2s blip).
|
|
75
|
+
const usageCardCache = new Map();
|
|
76
|
+
let cachedGlyphSet;
|
|
77
|
+
function glyphSetFor(paths) {
|
|
78
|
+
if (cachedGlyphSet === undefined) {
|
|
79
|
+
cachedGlyphSet = loadGlyphSet(paths);
|
|
80
|
+
}
|
|
81
|
+
return cachedGlyphSet;
|
|
82
|
+
}
|
|
83
|
+
// Test seam: forces the next glyphSetFor() call to re-read from disk.
|
|
84
|
+
export function resetGlyphSetCache() {
|
|
85
|
+
cachedGlyphSet = undefined;
|
|
86
|
+
}
|
|
87
|
+
// SwiftBar's comma-pair light/dark selection for dropdown images is
|
|
88
|
+
// version-dependent: pre-2.1 mishandled it (#399) and 2.1.x fixed it
|
|
89
|
+
// inverted — a light menu gets the dark half, which renders white text on a
|
|
90
|
+
// light menu. So never emit a pair for the card; read the system appearance
|
|
91
|
+
// here and emit the single matching image (a lone image renders identically
|
|
92
|
+
// on every SwiftBar version). CODING_USAGE_BAR_APPEARANCE=light|dark
|
|
93
|
+
// overrides for tests and manual forcing.
|
|
94
|
+
function systemPrefersDark() {
|
|
95
|
+
const override = process.env.CODING_USAGE_BAR_APPEARANCE?.toLowerCase();
|
|
96
|
+
if (override === "dark") {
|
|
97
|
+
return true;
|
|
73
98
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
scale: TITLE_IMAGE_SCALE,
|
|
77
|
-
barWidth: 80,
|
|
78
|
-
barHeight: 10,
|
|
79
|
-
barRadius: 3,
|
|
80
|
-
light: { barBgColor: "000000", barBgAlpha: 0.15 },
|
|
81
|
-
dark: { barBgColor: "FFFFFF", barBgAlpha: 0.2 },
|
|
82
|
-
};
|
|
83
|
-
const cacheKey = JSON.stringify(payload);
|
|
84
|
-
if (dropdownBarCache.has(cacheKey)) {
|
|
85
|
-
const cached = dropdownBarCache.get(cacheKey);
|
|
86
|
-
return cached ? cached.images.map((img) => ({ image: img, width: cached.width, height: cached.height })) : [];
|
|
99
|
+
if (override === "light") {
|
|
100
|
+
return false;
|
|
87
101
|
}
|
|
88
102
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
// `defaults` writes a timestamped PID line plus a "does not exist" error
|
|
104
|
+
// to stderr even on a successful read; in light mode the key is missing,
|
|
105
|
+
// so the child exits non-zero and execFileSync attaches that stderr to
|
|
106
|
+
// the thrown Error. Pipe-discard for stderr prevents either path from
|
|
107
|
+
// leaking into render output and breaking SwiftBar byte-stability.
|
|
108
|
+
const style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
timeout: 3000,
|
|
111
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
112
|
+
});
|
|
113
|
+
return style.trim() === "Dark";
|
|
92
114
|
}
|
|
93
115
|
catch {
|
|
94
|
-
|
|
95
|
-
return
|
|
116
|
+
// The key is absent in light appearance.
|
|
117
|
+
return false;
|
|
96
118
|
}
|
|
97
119
|
}
|
|
98
120
|
function appCliPath(paths) {
|
|
@@ -363,7 +385,12 @@ function providerIconPath(provider) {
|
|
|
363
385
|
const iconPath = path.join(ASSET_DIR, asset);
|
|
364
386
|
return fs.existsSync(iconPath) ? iconPath : null;
|
|
365
387
|
}
|
|
366
|
-
|
|
388
|
+
// Emits a single appearance-matched image (no light,dark pair) for the same
|
|
389
|
+
// reason as the dropdown card: SwiftBar's pair selection is inverted in
|
|
390
|
+
// 2.1.x, which would e.g. put the white dark-mode GLM mark on a light menu
|
|
391
|
+
// bar. The dark title variant only differs in bar tones and dark logo marks,
|
|
392
|
+
// but an inverted pick still degrades it.
|
|
393
|
+
function titleImageValue(providers, prefersDark) {
|
|
367
394
|
const segments = providers.map((provider) => {
|
|
368
395
|
const p = provider.usage.provider;
|
|
369
396
|
const darkAsset = PROVIDER_ICON_DARK_ASSET[p];
|
|
@@ -405,17 +432,18 @@ function titleImageValue(providers) {
|
|
|
405
432
|
barBgAlpha: 0.25,
|
|
406
433
|
},
|
|
407
434
|
};
|
|
408
|
-
const cacheKey = JSON.stringify(payload)
|
|
435
|
+
const cacheKey = `${prefersDark ? "dark" : "light"}:${JSON.stringify(payload)}`;
|
|
409
436
|
if (titleImageCache.has(cacheKey)) {
|
|
410
437
|
return titleImageCache.get(cacheKey);
|
|
411
438
|
}
|
|
412
439
|
try {
|
|
413
440
|
const images = renderTitleImage(payload);
|
|
414
|
-
const
|
|
441
|
+
const chosen = prefersDark ? images.dark : images.light;
|
|
442
|
+
const value = chosen?.image && chosen.width && chosen.height
|
|
415
443
|
? {
|
|
416
|
-
image:
|
|
417
|
-
width:
|
|
418
|
-
height:
|
|
444
|
+
image: chosen.image,
|
|
445
|
+
width: chosen.width,
|
|
446
|
+
height: chosen.height,
|
|
419
447
|
}
|
|
420
448
|
: null;
|
|
421
449
|
titleImageCache.set(cacheKey, value);
|
|
@@ -452,18 +480,8 @@ function coloredMeter(usedPercent, fillColor, width = METER_WIDTH) {
|
|
|
452
480
|
return (filled > 0 ? ansiRGB(fillColor, "█".repeat(filled)) : "")
|
|
453
481
|
+ (empty > 0 ? ansiRGB(MUTED_COLOR, "░".repeat(empty)) : "");
|
|
454
482
|
}
|
|
455
|
-
function usageLine(label, usedPercent, resetsAt, color, now
|
|
483
|
+
function usageLine(label, usedPercent, resetsAt, color, now) {
|
|
456
484
|
const percent = `${Math.round(usedPercent).toString().padStart(3)}%`;
|
|
457
|
-
if (barImage) {
|
|
458
|
-
return line(`${label} ${percent} reset ${formatResetTime(resetsAt, now)}`, {
|
|
459
|
-
image: barImage.image,
|
|
460
|
-
width: barImage.width,
|
|
461
|
-
height: barImage.height,
|
|
462
|
-
color,
|
|
463
|
-
font: ROW_FONT,
|
|
464
|
-
size: 12,
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
485
|
const bar = coloredMeter(usedPercent, color);
|
|
468
486
|
return line(`${label} ${bar} ${ansiRGB(color, percent)} reset ${formatResetTime(resetsAt, now)}`, {
|
|
469
487
|
ansi: true,
|
|
@@ -478,11 +496,10 @@ function providerBadge(provider) {
|
|
|
478
496
|
}
|
|
479
497
|
return `${Math.round(fiveHour.usedPercent)}%`;
|
|
480
498
|
}
|
|
499
|
+
// Text-fallback rows must stay image-free (see the dropdown card comment), so
|
|
500
|
+
// provider rows always use SF Symbols here; PNG marks appear only inside the
|
|
501
|
+
// title image and the composite card.
|
|
481
502
|
function providerIconParams(provider) {
|
|
482
|
-
const image = imageAssetBase64(PROVIDER_ICON_ASSET[provider] ?? "");
|
|
483
|
-
if (image) {
|
|
484
|
-
return { image };
|
|
485
|
-
}
|
|
486
503
|
return { sfimage: PROVIDER_ICON_FALLBACK[provider] ?? "terminal.fill" };
|
|
487
504
|
}
|
|
488
505
|
function titleIconParams() {
|
|
@@ -519,6 +536,130 @@ function issueLabel(code) {
|
|
|
519
536
|
}
|
|
520
537
|
return code.replaceAll("_", " ").toLowerCase();
|
|
521
538
|
}
|
|
539
|
+
// Compact one-liners for the card: the state pill already carries the
|
|
540
|
+
// verdict, so the message only keeps the numbers that inform the next action.
|
|
541
|
+
function shortCardMessage(provider) {
|
|
542
|
+
if (provider.usage.blocked) {
|
|
543
|
+
return "Monthly quota exhausted — frozen until the next billing cycle.";
|
|
544
|
+
}
|
|
545
|
+
const target = provider.analysis.target;
|
|
546
|
+
const range = target ? `${target.minPercent.toFixed(1)}%-${target.maxPercent.toFixed(1)}%` : null;
|
|
547
|
+
switch (provider.analysis.state) {
|
|
548
|
+
case "LIMIT_RISK":
|
|
549
|
+
return "Near plan limit — switch provider or slow down.";
|
|
550
|
+
case "OVER_BURN":
|
|
551
|
+
return range ? `Burning fast (target ${range}).` : "Burning fast — consider slowing down.";
|
|
552
|
+
case "UNDER_BURN":
|
|
553
|
+
return range ? `Below target (${range}).` : "Below burn target.";
|
|
554
|
+
case "ON_TRACK":
|
|
555
|
+
return range ? `On pace (target ${range}).` : "On pace.";
|
|
556
|
+
default:
|
|
557
|
+
return "Learning burn pattern — advice once samples suffice.";
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function cardUpdatedLabel(snapshot) {
|
|
561
|
+
const generated = new Date(snapshot.generatedAt);
|
|
562
|
+
if (Number.isNaN(generated.getTime())) {
|
|
563
|
+
return "";
|
|
564
|
+
}
|
|
565
|
+
const clock = `${String(generated.getHours()).padStart(2, "0")}:${String(generated.getMinutes()).padStart(2, "0")}`;
|
|
566
|
+
return `${generated.getMonth() + 1}/${generated.getDate()} ${clock}`;
|
|
567
|
+
}
|
|
568
|
+
// Per-provider observation timestamp shown next to the advice line. This
|
|
569
|
+
// replaces the old stale-usage WARNING text rows: a visible "updated ..."
|
|
570
|
+
// that lags tells the same story without stretching the menu wider than the
|
|
571
|
+
// card (SwiftBar sizes the menu to its widest row).
|
|
572
|
+
function observedUpdatedLabel(item, now) {
|
|
573
|
+
const observed = new Date(item.meta.observedAt);
|
|
574
|
+
if (Number.isNaN(observed.getTime())) {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
const clock = `${String(observed.getHours()).padStart(2, "0")}:${String(observed.getMinutes()).padStart(2, "0")}`;
|
|
578
|
+
const sameDay = observed.getFullYear() === now.getFullYear()
|
|
579
|
+
&& observed.getMonth() === now.getMonth()
|
|
580
|
+
&& observed.getDate() === now.getDate();
|
|
581
|
+
return `updated ${sameDay ? clock : `${observed.getMonth() + 1}/${observed.getDate()} ${clock}`}`;
|
|
582
|
+
}
|
|
583
|
+
function cardProviderBlock(item, now) {
|
|
584
|
+
const providerId = item.usage.provider;
|
|
585
|
+
const balanceOnly = Boolean(item.usage.balance && item.usage.windows.length === 0);
|
|
586
|
+
const blocked = Boolean(item.usage.blocked);
|
|
587
|
+
const colorPair = blocked
|
|
588
|
+
? MUTED_COLOR
|
|
589
|
+
: balanceOnly
|
|
590
|
+
? (item.usage.balance?.isAvailable ? OK_COLOR : ALERT_COLOR)
|
|
591
|
+
: STATE_COLOR[item.analysis.state];
|
|
592
|
+
const [lightColor, darkColor] = colorPair.split(",");
|
|
593
|
+
const darkAsset = PROVIDER_ICON_DARK_ASSET[providerId];
|
|
594
|
+
const darkPath = darkAsset ? path.join(ASSET_DIR, darkAsset) : null;
|
|
595
|
+
if (balanceOnly && item.usage.balance) {
|
|
596
|
+
const currency = item.usage.balance.currency === "CNY" ? "¥" : "$";
|
|
597
|
+
return {
|
|
598
|
+
name: formatProviderLabel(providerId),
|
|
599
|
+
iconPath: providerIconPath(providerId),
|
|
600
|
+
iconPathDark: darkPath && fs.existsSync(darkPath) ? darkPath : null,
|
|
601
|
+
stateLabel: item.usage.balance.isAvailable ? "Balance" : "Depleted",
|
|
602
|
+
lightColor,
|
|
603
|
+
darkColor,
|
|
604
|
+
headline: `${currency}${item.usage.balance.total}`,
|
|
605
|
+
rows: [],
|
|
606
|
+
note: "Pay-as-you-go · no windowed quota",
|
|
607
|
+
message: null,
|
|
608
|
+
updated: observedUpdatedLabel(item, now),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
const five = windowByName(item, "five_hour");
|
|
612
|
+
const seven = windowByName(item, "seven_day");
|
|
613
|
+
const rows = [];
|
|
614
|
+
if (five) {
|
|
615
|
+
rows.push({ label: "5h", pct: Math.round(five.usedPercent), resetLabel: `reset ${formatResetTime(five.resetsAt, now)}` });
|
|
616
|
+
}
|
|
617
|
+
if (seven) {
|
|
618
|
+
rows.push({ label: "7d", pct: Math.round(seven.usedPercent), resetLabel: `reset ${formatResetTime(seven.resetsAt, now)}` });
|
|
619
|
+
}
|
|
620
|
+
const headlinePct = five ?? seven;
|
|
621
|
+
return {
|
|
622
|
+
name: formatProviderLabel(providerId),
|
|
623
|
+
iconPath: providerIconPath(providerId),
|
|
624
|
+
iconPathDark: darkPath && fs.existsSync(darkPath) ? darkPath : null,
|
|
625
|
+
stateLabel: blocked ? "Frozen" : STATE_LABEL[item.analysis.state],
|
|
626
|
+
lightColor,
|
|
627
|
+
darkColor,
|
|
628
|
+
headline: headlinePct ? `${Math.round(headlinePct.usedPercent)}%` : STATE_LABEL[item.analysis.state],
|
|
629
|
+
rows,
|
|
630
|
+
note: null,
|
|
631
|
+
message: shortCardMessage(item),
|
|
632
|
+
updated: observedUpdatedLabel(item, now),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function usageCardValue(snapshot, providers, glyphs, now, prefersDark) {
|
|
636
|
+
const payload = {
|
|
637
|
+
profile: snapshot.profile.toUpperCase(),
|
|
638
|
+
updatedLabel: cardUpdatedLabel(snapshot),
|
|
639
|
+
providers: providers.map((item) => cardProviderBlock(item, now)),
|
|
640
|
+
scale: TITLE_IMAGE_SCALE,
|
|
641
|
+
};
|
|
642
|
+
const cacheKey = `${prefersDark ? "dark" : "light"}:${JSON.stringify(payload)}`;
|
|
643
|
+
if (usageCardCache.has(cacheKey)) {
|
|
644
|
+
return usageCardCache.get(cacheKey) ?? null;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const chosen = renderUsageCard(payload, glyphs, prefersDark ? "dark" : "light");
|
|
648
|
+
const value = chosen?.image
|
|
649
|
+
? {
|
|
650
|
+
image: chosen.image,
|
|
651
|
+
width: chosen.width,
|
|
652
|
+
height: chosen.height,
|
|
653
|
+
}
|
|
654
|
+
: null;
|
|
655
|
+
usageCardCache.set(cacheKey, value);
|
|
656
|
+
return value;
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
usageCardCache.set(cacheKey, null);
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
522
663
|
// `now` only feeds low-frequency decisions (reset due/day prefix). The output
|
|
523
664
|
// must stay byte-identical across renders while the snapshot is unchanged, so
|
|
524
665
|
// SwiftBar's content guard can skip the menu rebuild and menu bar repaint;
|
|
@@ -548,7 +689,8 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
548
689
|
}
|
|
549
690
|
const topState = [...providers].sort((left, right) => (STATE_PRIORITY[left.analysis.state] - STATE_PRIORITY[right.analysis.state]))[0]?.analysis.state
|
|
550
691
|
?? "RAW";
|
|
551
|
-
const
|
|
692
|
+
const prefersDark = systemPrefersDark();
|
|
693
|
+
const titleImage = titleImageValue(providers, prefersDark);
|
|
552
694
|
const lines = [
|
|
553
695
|
compact
|
|
554
696
|
? line("", {
|
|
@@ -570,76 +712,80 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
570
712
|
dropdown: false,
|
|
571
713
|
}),
|
|
572
714
|
"---",
|
|
573
|
-
|
|
715
|
+
];
|
|
716
|
+
const glyphs = glyphSetFor(paths);
|
|
717
|
+
const card = glyphs ? usageCardValue(snapshot, providers, glyphs, now, prefersDark) : null;
|
|
718
|
+
if (card) {
|
|
719
|
+
lines.push(line("", {
|
|
720
|
+
image: card.image,
|
|
721
|
+
width: card.width,
|
|
722
|
+
height: card.height,
|
|
723
|
+
}));
|
|
724
|
+
lines.push("---");
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
// Text-only fallback when glyph atlases are missing (never baked or
|
|
728
|
+
// unreadable): zero dropdown images so the storm cannot come back.
|
|
729
|
+
lines.push(line("Coding Usage Bar", {
|
|
574
730
|
color: TEXT_COLOR,
|
|
575
731
|
size: 15,
|
|
576
732
|
sfimage: "flame.fill",
|
|
577
733
|
sfcolor: STATE_COLOR[topState],
|
|
578
734
|
badge: snapshot.profile.toUpperCase(),
|
|
579
|
-
})
|
|
580
|
-
muted(anyProviderStale(snapshot) ? "Data stale" : "Data fresh")
|
|
581
|
-
"---"
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const currency = item.usage.balance.currency === "CNY" ? "¥" : "$";
|
|
606
|
-
const balanceText = `${currency}${item.usage.balance.total}`;
|
|
607
|
-
const availableLabel = item.usage.balance.isAvailable ? "Available" : "Depleted";
|
|
608
|
-
lines.push(line(`${formatProviderLabel(item.usage.provider)} ${availableLabel}`, {
|
|
735
|
+
}));
|
|
736
|
+
lines.push(muted(anyProviderStale(snapshot) ? "Data stale" : "Data fresh"));
|
|
737
|
+
lines.push("---");
|
|
738
|
+
for (const item of providers) {
|
|
739
|
+
const five = windowByName(item, "five_hour");
|
|
740
|
+
const seven = windowByName(item, "seven_day");
|
|
741
|
+
if (item.usage.balance && item.usage.windows.length === 0) {
|
|
742
|
+
const currency = item.usage.balance.currency === "CNY" ? "¥" : "$";
|
|
743
|
+
const balanceText = `${currency}${item.usage.balance.total}`;
|
|
744
|
+
const availableLabel = item.usage.balance.isAvailable ? "Available" : "Depleted";
|
|
745
|
+
lines.push(line(`${formatProviderLabel(item.usage.provider)} ${availableLabel}`, {
|
|
746
|
+
...providerIconParams(item.usage.provider),
|
|
747
|
+
color: TEXT_COLOR,
|
|
748
|
+
size: 14,
|
|
749
|
+
badge: balanceText,
|
|
750
|
+
}));
|
|
751
|
+
lines.push(line(`Balance ${balanceText}`, {
|
|
752
|
+
color: TEXT_COLOR,
|
|
753
|
+
font: ROW_FONT,
|
|
754
|
+
size: 12,
|
|
755
|
+
}));
|
|
756
|
+
lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
|
757
|
+
lines.push("---");
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
lines.push(line(`${formatProviderLabel(item.usage.provider)} ${item.usage.blocked ? "Blocked" : STATE_LABEL[item.analysis.state]}`, {
|
|
609
761
|
...providerIconParams(item.usage.provider),
|
|
610
762
|
color: TEXT_COLOR,
|
|
611
763
|
size: 14,
|
|
612
|
-
badge:
|
|
613
|
-
}));
|
|
614
|
-
lines.push(line(`Balance ${balanceText}`, {
|
|
615
|
-
color: TEXT_COLOR,
|
|
616
|
-
font: ROW_FONT,
|
|
617
|
-
size: 12,
|
|
764
|
+
badge: item.usage.blocked ? "Frozen" : providerBadge(item),
|
|
618
765
|
}));
|
|
766
|
+
if (five) {
|
|
767
|
+
lines.push(usageLine("5h", five.usedPercent, five.resetsAt, TEXT_COLOR, now));
|
|
768
|
+
}
|
|
769
|
+
if (seven) {
|
|
770
|
+
lines.push(usageLine("7d", seven.usedPercent, seven.resetsAt, TEXT_COLOR, now));
|
|
771
|
+
}
|
|
772
|
+
if (item.usage.blocked) {
|
|
773
|
+
lines.push(muted(`Blocked ${item.usage.blocked.reason}. 5h/7d numbers above still reflect the window quotas.`));
|
|
774
|
+
}
|
|
775
|
+
lines.push(muted(targetLabel(item)));
|
|
619
776
|
lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
|
620
777
|
lines.push("---");
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
lines.push(line(`${formatProviderLabel(item.usage.provider)} ${item.usage.blocked ? "Blocked" : STATE_LABEL[item.analysis.state]}`, {
|
|
624
|
-
...providerIconParams(item.usage.provider),
|
|
625
|
-
color: TEXT_COLOR,
|
|
626
|
-
size: 14,
|
|
627
|
-
badge: item.usage.blocked ? "Frozen" : providerBadge(item),
|
|
628
|
-
}));
|
|
629
|
-
if (five) {
|
|
630
|
-
lines.push(usageLine("5h", five.usedPercent, five.resetsAt, TEXT_COLOR, now, dropdownBars[barIdx++]));
|
|
631
|
-
}
|
|
632
|
-
if (seven) {
|
|
633
|
-
lines.push(usageLine("7d", seven.usedPercent, seven.resetsAt, TEXT_COLOR, now, dropdownBars[barIdx++]));
|
|
634
778
|
}
|
|
635
|
-
if (item.usage.blocked) {
|
|
636
|
-
lines.push(muted(`Blocked ${item.usage.blocked.reason}. 5h/7d numbers above still reflect the window quotas.`));
|
|
637
|
-
}
|
|
638
|
-
lines.push(muted(targetLabel(item)));
|
|
639
|
-
lines.push(line(item.analysis.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
|
640
|
-
lines.push("---");
|
|
641
779
|
}
|
|
642
|
-
|
|
780
|
+
// Card mode folds staleness into the per-provider "updated ..." labels, so
|
|
781
|
+
// warning-severity issue rows (stale usage, cache fallback) are dropped
|
|
782
|
+
// there; error-severity rows (setup problems the card cannot express) stay.
|
|
783
|
+
// Long issue messages are length-capped so they can never stretch the menu
|
|
784
|
+
// wider than the card image.
|
|
785
|
+
const visibleIssues = card
|
|
786
|
+
? snapshot.issues.filter((issue) => issue.severity === "error")
|
|
787
|
+
: snapshot.issues;
|
|
788
|
+
for (const issue of visibleIssues) {
|
|
643
789
|
const color = issue.severity === "error" ? ALERT_COLOR : WARNING_COLOR;
|
|
644
790
|
lines.push(line(`${issue.severity.toUpperCase()} ${issueLabel(issue.code)}`, {
|
|
645
791
|
color,
|
|
@@ -647,9 +793,9 @@ export function renderMenuBar(snapshot = loadDisplayStatusSnapshot(), paths = bu
|
|
|
647
793
|
sfimage: "exclamationmark.triangle.fill",
|
|
648
794
|
sfcolor: color,
|
|
649
795
|
}));
|
|
650
|
-
lines.push(
|
|
796
|
+
lines.push(line(issue.message, { color: MUTED_COLOR, size: 12, length: 84 }));
|
|
651
797
|
}
|
|
652
|
-
if (
|
|
798
|
+
if (visibleIssues.length > 0) {
|
|
653
799
|
lines.push("---");
|
|
654
800
|
}
|
|
655
801
|
lines.push(line(compact ? "Expand" : "Collapse", toggleParams));
|
package/dist/notifier.d.ts
CHANGED
|
@@ -3,5 +3,6 @@ export declare function commandExists(command: string): boolean;
|
|
|
3
3
|
export declare function notificationBackend(): "osascript" | "terminal-notifier" | "burnt-toast" | "notify-send" | "unsupported";
|
|
4
4
|
export declare function shouldNotify(analysis: BurnAnalysis, paths?: RuntimePaths, now?: Date): boolean;
|
|
5
5
|
export declare function markNotified(analysis: BurnAnalysis, paths?: RuntimePaths, now?: Date): void;
|
|
6
|
+
export declare function markRecovered(analysis: BurnAnalysis, paths?: RuntimePaths): void;
|
|
6
7
|
export declare function iconForAnalysis(analysis: BurnAnalysis): string;
|
|
7
8
|
export declare function sendNotification(analysis: BurnAnalysis, dryRun?: boolean): string;
|