coding-usage-bar 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +6 -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/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runDaemonOnce } from "./daemon.js";
|
|
|
8
8
|
import { doctorHasFailures, formatDoctor, runDoctor } from "./doctor.js";
|
|
9
9
|
import { installMenuBar, renderMenuBar, uninstallMenuBar, toggleCompactMode } from "./menubar.js";
|
|
10
10
|
import { buildPaths } from "./paths.js";
|
|
11
|
+
import { bakeGlyphAtlases } from "./glyphs.js";
|
|
11
12
|
import { maybePromptForStar } from "./star.js";
|
|
12
13
|
function hasFlag(args, flag) {
|
|
13
14
|
return args.includes(flag);
|
|
@@ -24,6 +25,7 @@ Usage:
|
|
|
24
25
|
coding-usage-bar menubar install [--dry-run]
|
|
25
26
|
coding-usage-bar menubar uninstall [--dry-run]
|
|
26
27
|
coding-usage-bar menubar toggle-compact
|
|
28
|
+
coding-usage-bar menubar bake-glyphs [--dry-run]
|
|
27
29
|
coding-usage-bar ingest claude-statusline
|
|
28
30
|
coding-usage-bar daemon [--once] [--dry-run]
|
|
29
31
|
`);
|
|
@@ -108,6 +110,10 @@ async function main() {
|
|
|
108
110
|
console.log(isCompact ? "Compact" : "Expanded");
|
|
109
111
|
return;
|
|
110
112
|
}
|
|
113
|
+
if (command === "menubar" && subcommand === "bake-glyphs") {
|
|
114
|
+
console.log(bakeGlyphAtlases(buildPaths(), { dryRun }).join("\n"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
111
117
|
if (command === "ingest" && subcommand === "claude-statusline") {
|
|
112
118
|
const input = await readStdin();
|
|
113
119
|
const parsed = JSON.parse(input);
|
package/dist/daemon.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { collectStatusSnapshot } from "./runtime.js";
|
|
2
|
-
import { markNotified, sendNotification, shouldNotify } from "./notifier.js";
|
|
2
|
+
import { markNotified, markRecovered, sendNotification, shouldNotify } from "./notifier.js";
|
|
3
3
|
export async function runDaemonOnce(options = {}) {
|
|
4
4
|
const snapshot = await collectStatusSnapshot();
|
|
5
5
|
const messages = [];
|
|
6
6
|
for (const { analysis } of snapshot.providers) {
|
|
7
7
|
if (!shouldNotify(analysis)) {
|
|
8
|
+
if (!options.dryRun) {
|
|
9
|
+
markRecovered(analysis);
|
|
10
|
+
}
|
|
8
11
|
continue;
|
|
9
12
|
}
|
|
10
13
|
messages.push(sendNotification(analysis, options.dryRun));
|
package/dist/glm.d.ts
CHANGED
package/dist/glm.js
CHANGED
|
@@ -16,24 +16,54 @@ export async function fetchGlmQuota(config) {
|
|
|
16
16
|
}
|
|
17
17
|
return (await response.json());
|
|
18
18
|
}
|
|
19
|
+
function resetTimeIso(value) {
|
|
20
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const date = new Date(value);
|
|
24
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
25
|
+
}
|
|
26
|
+
// GLM identifies windows by shape: unit 3 + number 5 is the 5-hour window,
|
|
27
|
+
// unit 6 + number 1 is the weekly window. Matching on shape (with a
|
|
28
|
+
// reset-time sort as fallback) matters because sorting alone breaks when a
|
|
29
|
+
// window omits nextResetTime — which GLM does for a fresh window with zero
|
|
30
|
+
// usage (same proto-style field omission as Kimi's zero `used`). The zero
|
|
31
|
+
// percentage is a real signal and must not be dropped; a missing reset time
|
|
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;
|
|
40
|
+
if (!five || !seven) {
|
|
41
|
+
const rest = tokenLimits
|
|
42
|
+
.filter((limit) => limit !== five && limit !== seven)
|
|
43
|
+
.sort((a, b) => (a.nextResetTime ?? 0) - (b.nextResetTime ?? 0));
|
|
44
|
+
five = five ?? rest.shift() ?? null;
|
|
45
|
+
seven = seven ?? rest.shift() ?? null;
|
|
46
|
+
}
|
|
47
|
+
return five && seven ? { five, seven } : null;
|
|
48
|
+
}
|
|
19
49
|
export function usageFromGlmQuota(quota, options) {
|
|
20
50
|
if (!quota.success || !quota.data) {
|
|
21
51
|
return null;
|
|
22
52
|
}
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
53
|
+
const picked = pickTokenWindows(quota.data.limits);
|
|
54
|
+
if (!picked) {
|
|
25
55
|
return null;
|
|
26
56
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
57
|
+
// new Date(undefined).toISOString() throws RangeError("Invalid time value"),
|
|
58
|
+
// which used to fail the whole live collection; never convert unchecked.
|
|
59
|
+
const fallbackIso = options.observedAt ?? new Date().toISOString();
|
|
30
60
|
const fiveHour = normalizeWindow("five_hour", {
|
|
31
|
-
used_percent:
|
|
32
|
-
resets_at:
|
|
61
|
+
used_percent: picked.five.percentage,
|
|
62
|
+
resets_at: resetTimeIso(picked.five.nextResetTime) ?? fallbackIso,
|
|
33
63
|
}, 300);
|
|
34
64
|
const sevenDay = normalizeWindow("seven_day", {
|
|
35
|
-
used_percent:
|
|
36
|
-
resets_at:
|
|
65
|
+
used_percent: picked.seven.percentage,
|
|
66
|
+
resets_at: resetTimeIso(picked.seven.nextResetTime) ?? fallbackIso,
|
|
37
67
|
}, 10080);
|
|
38
68
|
if (!fiveHour || !sevenDay) {
|
|
39
69
|
return null;
|
package/dist/glyphs.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DecodedPNG } from "./png.js";
|
|
2
|
+
import { RuntimePaths } from "./types.js";
|
|
3
|
+
export declare const GLYPH_STYLES: readonly [{
|
|
4
|
+
readonly key: "r22";
|
|
5
|
+
readonly px: 22;
|
|
6
|
+
readonly weight: "regular";
|
|
7
|
+
}, {
|
|
8
|
+
readonly key: "r24";
|
|
9
|
+
readonly px: 24;
|
|
10
|
+
readonly weight: "regular";
|
|
11
|
+
}, {
|
|
12
|
+
readonly key: "s22";
|
|
13
|
+
readonly px: 22;
|
|
14
|
+
readonly weight: "semibold";
|
|
15
|
+
}, {
|
|
16
|
+
readonly key: "s28";
|
|
17
|
+
readonly px: 28;
|
|
18
|
+
readonly weight: "semibold";
|
|
19
|
+
}, {
|
|
20
|
+
readonly key: "b20";
|
|
21
|
+
readonly px: 20;
|
|
22
|
+
readonly weight: "bold";
|
|
23
|
+
}, {
|
|
24
|
+
readonly key: "b24";
|
|
25
|
+
readonly px: 24;
|
|
26
|
+
readonly weight: "bold";
|
|
27
|
+
}, {
|
|
28
|
+
readonly key: "b26";
|
|
29
|
+
readonly px: 26;
|
|
30
|
+
readonly weight: "bold";
|
|
31
|
+
}, {
|
|
32
|
+
readonly key: "b38";
|
|
33
|
+
readonly px: 38;
|
|
34
|
+
readonly weight: "bold";
|
|
35
|
+
}];
|
|
36
|
+
export type GlyphStyleKey = (typeof GLYPH_STYLES)[number]["key"];
|
|
37
|
+
export declare function glyphCharset(): string[];
|
|
38
|
+
export interface GlyphCell {
|
|
39
|
+
x: number;
|
|
40
|
+
w: number;
|
|
41
|
+
adv: number;
|
|
42
|
+
}
|
|
43
|
+
export interface GlyphStyleMetrics {
|
|
44
|
+
px: number;
|
|
45
|
+
weight: string;
|
|
46
|
+
ascent: number;
|
|
47
|
+
descent: number;
|
|
48
|
+
pad: number;
|
|
49
|
+
cellH: number;
|
|
50
|
+
glyphs: Record<string, GlyphCell>;
|
|
51
|
+
}
|
|
52
|
+
export interface GlyphMetricsFile {
|
|
53
|
+
version: number;
|
|
54
|
+
styles: Record<string, GlyphStyleMetrics>;
|
|
55
|
+
}
|
|
56
|
+
export interface LoadedGlyphStyle {
|
|
57
|
+
metrics: GlyphStyleMetrics;
|
|
58
|
+
atlas: DecodedPNG;
|
|
59
|
+
}
|
|
60
|
+
export type GlyphSet = Record<string, LoadedGlyphStyle>;
|
|
61
|
+
export declare function glyphMetricsPath(paths: RuntimePaths): string;
|
|
62
|
+
export declare function glyphAtlasPath(paths: RuntimePaths, styleKey: string): string;
|
|
63
|
+
export declare function bakeGlyphAtlases(paths: RuntimePaths, options?: {
|
|
64
|
+
dryRun?: boolean;
|
|
65
|
+
}): string[];
|
|
66
|
+
export declare function loadGlyphSet(paths: RuntimePaths): GlyphSet | null;
|
|
67
|
+
export declare function measureText(style: LoadedGlyphStyle, text: string): number;
|
|
68
|
+
export declare function ellipsize(style: LoadedGlyphStyle, text: string, maxWidth: number): string;
|
package/dist/glyphs.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { decodePNG } from "./png.js";
|
|
5
|
+
// Glyph atlases let the pure-JS card renderer draw native-looking SF text
|
|
6
|
+
// without ever spawning AppKit at render time. Baking runs osascript exactly
|
|
7
|
+
// once, at install time, on the user's own machine (the SF fonts live there;
|
|
8
|
+
// nothing font-derived ships in the npm package). Render-time consumers only
|
|
9
|
+
// read the baked PNG + metrics files; when they are missing the menu bar
|
|
10
|
+
// falls back to text-only rows, so a failed bake can never break rendering.
|
|
11
|
+
// All sizes are physical pixels (2x point sizes; the card renders at scale 2).
|
|
12
|
+
export const GLYPH_STYLES = [
|
|
13
|
+
{ key: "r22", px: 22, weight: "regular" },
|
|
14
|
+
{ key: "r24", px: 24, weight: "regular" },
|
|
15
|
+
{ key: "s22", px: 22, weight: "semibold" },
|
|
16
|
+
{ key: "s28", px: 28, weight: "semibold" },
|
|
17
|
+
{ key: "b20", px: 20, weight: "bold" },
|
|
18
|
+
{ key: "b24", px: 24, weight: "bold" },
|
|
19
|
+
{ key: "b26", px: 26, weight: "bold" },
|
|
20
|
+
{ key: "b38", px: 38, weight: "bold" },
|
|
21
|
+
];
|
|
22
|
+
// Printable ASCII plus the few extras the card actually renders.
|
|
23
|
+
export function glyphCharset() {
|
|
24
|
+
const chars = [];
|
|
25
|
+
for (let code = 32; code <= 126; code++) {
|
|
26
|
+
chars.push(String.fromCharCode(code));
|
|
27
|
+
}
|
|
28
|
+
chars.push("¥", "·", "—", "…");
|
|
29
|
+
return chars;
|
|
30
|
+
}
|
|
31
|
+
export function glyphMetricsPath(paths) {
|
|
32
|
+
return path.join(paths.glyphsDir, "metrics.json");
|
|
33
|
+
}
|
|
34
|
+
export function glyphAtlasPath(paths, styleKey) {
|
|
35
|
+
return path.join(paths.glyphsDir, `${styleKey}.png`);
|
|
36
|
+
}
|
|
37
|
+
// JXA program executed by `osascript -l JavaScript`. It renders every charset
|
|
38
|
+
// glyph for every style into one horizontal PNG strip per style (white text on
|
|
39
|
+
// transparent, monospacedDigit variant so numbers stay tabular) and returns
|
|
40
|
+
// the metrics JSON on stdout. Kept dependency-free and side-effect-scoped to
|
|
41
|
+
// the output directory it is handed.
|
|
42
|
+
const BAKE_JXA = `
|
|
43
|
+
ObjC.import("Cocoa");
|
|
44
|
+
function run(argv) {
|
|
45
|
+
const spec = JSON.parse(argv[0]);
|
|
46
|
+
const weights = {
|
|
47
|
+
regular: $.NSFontWeightRegular,
|
|
48
|
+
semibold: $.NSFontWeightSemibold,
|
|
49
|
+
bold: $.NSFontWeightBold,
|
|
50
|
+
};
|
|
51
|
+
const out = { version: 1, styles: {} };
|
|
52
|
+
for (const style of spec.styles) {
|
|
53
|
+
const font = $.NSFont.monospacedDigitSystemFontOfSizeWeight(style.px, weights[style.weight]);
|
|
54
|
+
const attrs = $.NSDictionary.dictionaryWithObjectsForKeys(
|
|
55
|
+
$([font, $.NSColor.whiteColor]),
|
|
56
|
+
$([$.NSFontAttributeName, $.NSForegroundColorAttributeName]),
|
|
57
|
+
);
|
|
58
|
+
const ascent = Math.ceil(font.ascender);
|
|
59
|
+
const descent = Math.ceil(Math.abs(font.descender));
|
|
60
|
+
const pad = 2;
|
|
61
|
+
const cellH = ascent + descent + pad * 2;
|
|
62
|
+
const cells = [];
|
|
63
|
+
let stripW = 0;
|
|
64
|
+
for (const ch of spec.chars) {
|
|
65
|
+
const adv = $(ch).sizeWithAttributes(attrs).width;
|
|
66
|
+
const w = Math.ceil(adv) + pad * 2;
|
|
67
|
+
cells.push({ ch, x: stripW, w, adv });
|
|
68
|
+
stripW += w;
|
|
69
|
+
}
|
|
70
|
+
const rep = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(
|
|
71
|
+
null, stripW, cellH, 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0,
|
|
72
|
+
);
|
|
73
|
+
$.NSGraphicsContext.saveGraphicsState;
|
|
74
|
+
$.NSGraphicsContext.setCurrentContext($.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep));
|
|
75
|
+
for (const cell of cells) {
|
|
76
|
+
$(cell.ch).drawAtPointWithAttributes($.NSMakePoint(cell.x + pad, pad), attrs);
|
|
77
|
+
}
|
|
78
|
+
$.NSGraphicsContext.restoreGraphicsState;
|
|
79
|
+
const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $.NSDictionary.dictionary);
|
|
80
|
+
png.writeToFileAtomically($(spec.outDir + "/" + style.key + ".png"), true);
|
|
81
|
+
const glyphs = {};
|
|
82
|
+
for (const cell of cells) {
|
|
83
|
+
glyphs[cell.ch] = { x: cell.x, w: cell.w, adv: cell.adv };
|
|
84
|
+
}
|
|
85
|
+
out.styles[style.key] = {
|
|
86
|
+
px: style.px,
|
|
87
|
+
weight: style.weight,
|
|
88
|
+
ascent, descent, pad, cellH,
|
|
89
|
+
glyphs,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return JSON.stringify(out);
|
|
93
|
+
}
|
|
94
|
+
`;
|
|
95
|
+
export function bakeGlyphAtlases(paths, options = {}) {
|
|
96
|
+
if (process.platform !== "darwin") {
|
|
97
|
+
return ["Glyph atlas baking requires macOS; menu bar card rendering will use the text fallback."];
|
|
98
|
+
}
|
|
99
|
+
if (options.dryRun) {
|
|
100
|
+
return [`[dry-run] would bake ${GLYPH_STYLES.length} glyph atlases into ${paths.glyphsDir}`];
|
|
101
|
+
}
|
|
102
|
+
fs.mkdirSync(paths.glyphsDir, { recursive: true });
|
|
103
|
+
const spec = {
|
|
104
|
+
outDir: paths.glyphsDir,
|
|
105
|
+
chars: glyphCharset(),
|
|
106
|
+
styles: GLYPH_STYLES.map((style) => ({ key: style.key, px: style.px, weight: style.weight })),
|
|
107
|
+
};
|
|
108
|
+
const stdout = execFileSync("osascript", ["-l", "JavaScript", "-e", BAKE_JXA, JSON.stringify(spec)], {
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
timeout: 60_000,
|
|
111
|
+
});
|
|
112
|
+
const metrics = JSON.parse(stdout.trim());
|
|
113
|
+
for (const style of GLYPH_STYLES) {
|
|
114
|
+
if (!metrics.styles[style.key] || !fs.existsSync(glyphAtlasPath(paths, style.key))) {
|
|
115
|
+
throw new Error(`Glyph bake incomplete: missing atlas for style ${style.key}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
fs.writeFileSync(glyphMetricsPath(paths), `${JSON.stringify(metrics)}\n`, "utf8");
|
|
119
|
+
return [`Baked ${GLYPH_STYLES.length} glyph atlases: ${paths.glyphsDir}`];
|
|
120
|
+
}
|
|
121
|
+
// Returns null when any piece is missing or unreadable; callers treat null as
|
|
122
|
+
// "use the text-only fallback", never as an error.
|
|
123
|
+
export function loadGlyphSet(paths) {
|
|
124
|
+
let metrics;
|
|
125
|
+
try {
|
|
126
|
+
metrics = JSON.parse(fs.readFileSync(glyphMetricsPath(paths), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const set = {};
|
|
132
|
+
for (const style of GLYPH_STYLES) {
|
|
133
|
+
const styleMetrics = metrics.styles?.[style.key];
|
|
134
|
+
if (!styleMetrics) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const atlas = decodePNG(fs.readFileSync(glyphAtlasPath(paths, style.key)));
|
|
139
|
+
set[style.key] = { metrics: styleMetrics, atlas };
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return set;
|
|
146
|
+
}
|
|
147
|
+
export function measureText(style, text) {
|
|
148
|
+
let width = 0;
|
|
149
|
+
for (const ch of text) {
|
|
150
|
+
const cell = style.metrics.glyphs[ch] ?? style.metrics.glyphs["?"];
|
|
151
|
+
if (cell) {
|
|
152
|
+
width += cell.adv;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return width;
|
|
156
|
+
}
|
|
157
|
+
// Trims `text` with a trailing ellipsis so it fits within maxWidth pixels.
|
|
158
|
+
export function ellipsize(style, text, maxWidth) {
|
|
159
|
+
if (measureText(style, text) <= maxWidth) {
|
|
160
|
+
return text;
|
|
161
|
+
}
|
|
162
|
+
const ellipsis = "…";
|
|
163
|
+
const ellipsisWidth = measureText(style, ellipsis);
|
|
164
|
+
let result = "";
|
|
165
|
+
let width = 0;
|
|
166
|
+
for (const ch of text) {
|
|
167
|
+
const cell = style.metrics.glyphs[ch] ?? style.metrics.glyphs["?"];
|
|
168
|
+
const adv = cell ? cell.adv : 0;
|
|
169
|
+
if (width + adv + ellipsisWidth > maxWidth) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
result += ch;
|
|
173
|
+
width += adv;
|
|
174
|
+
}
|
|
175
|
+
return `${result.trimEnd()}${ellipsis}`;
|
|
176
|
+
}
|
package/dist/install.js
CHANGED
|
@@ -6,6 +6,7 @@ import { claudeStatusLineHasIngest, getClaudeStatusLineCommand, readClaudeSettin
|
|
|
6
6
|
import { ensureConfig, readConfig } from "./config.js";
|
|
7
7
|
import { ensureDir, isFile, readJsonFile, writeJsonAtomic } from "./fs-util.js";
|
|
8
8
|
import { addSwiftBarToLoginItems, ensureSwiftBarInstalled, installMenuBar, openSwiftBar, uninstallMenuBar } from "./menubar.js";
|
|
9
|
+
import { bakeGlyphAtlases } from "./glyphs.js";
|
|
9
10
|
import { buildPaths } from "./paths.js";
|
|
10
11
|
import { stableNodeExecutable } from "./node-runtime.js";
|
|
11
12
|
export const LAUNCHD_LABEL = "com.duying.coding-usage-bar";
|
|
@@ -354,6 +355,15 @@ export function install(options = {}) {
|
|
|
354
355
|
messages.push(" Get your key at: https://open.bigmodel.cn");
|
|
355
356
|
}
|
|
356
357
|
}
|
|
358
|
+
// The dropdown card needs baked glyph atlases; a failed bake must not fail
|
|
359
|
+
// the install — rendering falls back to text-only rows until a bake works.
|
|
360
|
+
try {
|
|
361
|
+
messages.push(...bakeGlyphAtlases(paths, { dryRun }));
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
messages.push(`Glyph atlas bake failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
365
|
+
messages.push("The menu bar dropdown will use the text-only fallback; retry with: coding-usage-bar menubar bake-glyphs");
|
|
366
|
+
}
|
|
357
367
|
messages.push(...ensureSwiftBarInstalled({ dryRun }));
|
|
358
368
|
messages.push(...installMenuBar({ dryRun }));
|
|
359
369
|
if (!dryRun) {
|
|
@@ -400,6 +410,15 @@ export function uninstall(options = {}) {
|
|
|
400
410
|
messages.push(`CLI shim is user-managed; leaving it unchanged: ${paths.cliBinFile}`);
|
|
401
411
|
}
|
|
402
412
|
messages.push(...uninstallMenuBar({ dryRun }));
|
|
413
|
+
if (fs.existsSync(paths.glyphsDir)) {
|
|
414
|
+
if (dryRun) {
|
|
415
|
+
messages.push(`[dry-run] would remove glyph atlases: ${paths.glyphsDir}`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
fs.rmSync(paths.glyphsDir, { recursive: true, force: true });
|
|
419
|
+
messages.push(`Removed glyph atlases: ${paths.glyphsDir}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
403
422
|
const settings = readClaudeSettings(paths.claudeSettingsFile);
|
|
404
423
|
const existing = getClaudeStatusLineCommand(settings);
|
|
405
424
|
if (existing === paths.claudeStatusLineScript && settings) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { GlyphSet } from "./glyphs.js";
|
|
2
|
+
export interface CardBarRow {
|
|
3
|
+
label: string;
|
|
4
|
+
pct: number;
|
|
5
|
+
resetLabel: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CardProviderBlock {
|
|
8
|
+
name: string;
|
|
9
|
+
iconPath: string | null;
|
|
10
|
+
iconPathDark: string | null;
|
|
11
|
+
stateLabel: string;
|
|
12
|
+
lightColor: string;
|
|
13
|
+
darkColor: string;
|
|
14
|
+
headline: string;
|
|
15
|
+
rows: CardBarRow[];
|
|
16
|
+
note: string | null;
|
|
17
|
+
message: string | null;
|
|
18
|
+
updated: string | null;
|
|
19
|
+
}
|
|
20
|
+
export interface UsageCardPayload {
|
|
21
|
+
profile: string;
|
|
22
|
+
updatedLabel: string;
|
|
23
|
+
providers: CardProviderBlock[];
|
|
24
|
+
scale: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function renderUsageCard(payload: UsageCardPayload, glyphs: GlyphSet, mode: "light" | "dark"): {
|
|
27
|
+
image: string;
|
|
28
|
+
width: number;
|
|
29
|
+
height: number;
|
|
30
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { Canvas, parseHex, loadIcon } from "./menubar-render.js";
|
|
2
|
+
import { measureText, ellipsize } from "./glyphs.js";
|
|
3
|
+
const LIGHT = {
|
|
4
|
+
text: parseHex("111827"),
|
|
5
|
+
muted: parseHex("6B7280"),
|
|
6
|
+
msg: parseHex("8E8E93"),
|
|
7
|
+
track: parseHex("000000"),
|
|
8
|
+
trackAlpha: 0.10,
|
|
9
|
+
sep: parseHex("000000"),
|
|
10
|
+
sepAlpha: 0.08,
|
|
11
|
+
};
|
|
12
|
+
const DARK = {
|
|
13
|
+
text: parseHex("F9FAFB"),
|
|
14
|
+
muted: parseHex("A1A1AA"),
|
|
15
|
+
msg: parseHex("7C7C82"),
|
|
16
|
+
track: parseHex("FFFFFF"),
|
|
17
|
+
trackAlpha: 0.12,
|
|
18
|
+
sep: parseHex("FFFFFF"),
|
|
19
|
+
sepAlpha: 0.09,
|
|
20
|
+
};
|
|
21
|
+
// Layout in points (multiplied by payload.scale when drawing). PAD_X is tiny
|
|
22
|
+
// because the menu item's own content inset already provides the left/right
|
|
23
|
+
// margin; the image's content edge then lines up with native item text.
|
|
24
|
+
const W = 460;
|
|
25
|
+
const PAD_X = 2;
|
|
26
|
+
const TOP_PAD = 8;
|
|
27
|
+
const HEADER_H = 18;
|
|
28
|
+
const HEADER_GAP = 12;
|
|
29
|
+
const BLOCK_PAD_Y = 10;
|
|
30
|
+
const HEAD_H = 24;
|
|
31
|
+
const ROW_H = 21;
|
|
32
|
+
const MSG_GAP = 6;
|
|
33
|
+
const MSG_H = 14;
|
|
34
|
+
const BOTTOM_PAD = 4;
|
|
35
|
+
const RIGHT_EDGE = W - PAD_X;
|
|
36
|
+
const RESET_COL_W = 104;
|
|
37
|
+
const PCT_COL_W = 36;
|
|
38
|
+
const COL_GAP = 8;
|
|
39
|
+
const TRACK_X = PAD_X + 18 + 16;
|
|
40
|
+
const PILL_H = 16;
|
|
41
|
+
const PILL_PAD_X = 7;
|
|
42
|
+
const PILL_RADIUS = 8;
|
|
43
|
+
function blockHeight(block) {
|
|
44
|
+
let h = BLOCK_PAD_Y * 2 + HEAD_H;
|
|
45
|
+
h += block.rows.length * ROW_H;
|
|
46
|
+
if (block.note) {
|
|
47
|
+
h += ROW_H;
|
|
48
|
+
}
|
|
49
|
+
if (block.message) {
|
|
50
|
+
h += MSG_GAP + MSG_H;
|
|
51
|
+
}
|
|
52
|
+
return h;
|
|
53
|
+
}
|
|
54
|
+
function cardHeight(payload) {
|
|
55
|
+
let h = TOP_PAD + HEADER_H + HEADER_GAP;
|
|
56
|
+
payload.providers.forEach((block, idx) => {
|
|
57
|
+
h += blockHeight(block);
|
|
58
|
+
if (idx < payload.providers.length - 1) {
|
|
59
|
+
h += 1;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return h + BOTTOM_PAD;
|
|
63
|
+
}
|
|
64
|
+
function style(ctx, key) {
|
|
65
|
+
const loaded = ctx.glyphs[key];
|
|
66
|
+
if (!loaded) {
|
|
67
|
+
throw new Error(`Glyph style missing from atlas set: ${key}`);
|
|
68
|
+
}
|
|
69
|
+
return loaded;
|
|
70
|
+
}
|
|
71
|
+
// `baselineTopPt` is the distance from the card's top edge to the text
|
|
72
|
+
// baseline, in points; conversion to the canvas' bottom-left origin happens
|
|
73
|
+
// here so the layout code can think strictly top-down.
|
|
74
|
+
function drawText(ctx, styleKey, text, xPt, baselineTopPt, color, alpha = 1) {
|
|
75
|
+
const loaded = style(ctx, styleKey);
|
|
76
|
+
const { atlas, metrics } = loaded;
|
|
77
|
+
const s = ctx.scale;
|
|
78
|
+
const cellBottom = ctx.heightPt * s - (baselineTopPt * s + metrics.descent + metrics.pad);
|
|
79
|
+
let cursor = xPt * s;
|
|
80
|
+
for (const ch of text) {
|
|
81
|
+
const cell = metrics.glyphs[ch] ?? metrics.glyphs["?"];
|
|
82
|
+
if (!cell) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
ctx.canvas.drawGlyphCell(atlas, cell.x, cell.w, cursor - metrics.pad, cellBottom, color, alpha);
|
|
86
|
+
cursor += cell.adv;
|
|
87
|
+
}
|
|
88
|
+
return cursor / s;
|
|
89
|
+
}
|
|
90
|
+
function textWidthPt(ctx, styleKey, text) {
|
|
91
|
+
return measureText(style(ctx, styleKey), text) / ctx.scale;
|
|
92
|
+
}
|
|
93
|
+
function fillRect(ctx, xPt, topPt, wPt, hPt, radiusPt, color, alpha) {
|
|
94
|
+
const s = ctx.scale;
|
|
95
|
+
ctx.canvas.fillRoundedRect(xPt * s, (ctx.heightPt - topPt - hPt) * s, wPt * s, hPt * s, radiusPt * s, color, alpha);
|
|
96
|
+
}
|
|
97
|
+
function drawPill(ctx, xPt, topPt, text, color) {
|
|
98
|
+
const textW = textWidthPt(ctx, "b20", text);
|
|
99
|
+
const pillW = textW + PILL_PAD_X * 2;
|
|
100
|
+
fillRect(ctx, xPt, topPt, pillW, PILL_H, PILL_RADIUS, color, 0.13);
|
|
101
|
+
drawText(ctx, "b20", text, xPt + PILL_PAD_X, topPt + 11.5, color);
|
|
102
|
+
return pillW;
|
|
103
|
+
}
|
|
104
|
+
function drawProviderBlock(ctx, block, topPt, mode, variant) {
|
|
105
|
+
const color = parseHex(mode === "dark" ? block.darkColor : block.lightColor);
|
|
106
|
+
let y = topPt + BLOCK_PAD_Y;
|
|
107
|
+
const iconFile = mode === "dark" && block.iconPathDark ? block.iconPathDark : block.iconPath;
|
|
108
|
+
if (iconFile) {
|
|
109
|
+
const icon = loadIcon(iconFile);
|
|
110
|
+
if (icon) {
|
|
111
|
+
const s = ctx.scale;
|
|
112
|
+
ctx.canvas.drawIcon(icon, PAD_X * s, (ctx.heightPt - (y + 3) - 18) * s, 18 * s);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const nameEnd = drawText(ctx, "s28", block.name, PAD_X + 26, y + 17, variant.text);
|
|
116
|
+
drawPill(ctx, nameEnd + 8, y + 4, block.stateLabel, color);
|
|
117
|
+
const headlineW = textWidthPt(ctx, "b38", block.headline);
|
|
118
|
+
drawText(ctx, "b38", block.headline, RIGHT_EDGE - headlineW, y + 19, color);
|
|
119
|
+
y += HEAD_H;
|
|
120
|
+
for (const row of block.rows) {
|
|
121
|
+
const baseline = y + 14;
|
|
122
|
+
drawText(ctx, "s22", row.label, PAD_X, baseline, variant.muted);
|
|
123
|
+
const trackW = RIGHT_EDGE - RESET_COL_W - COL_GAP - PCT_COL_W - COL_GAP - TRACK_X;
|
|
124
|
+
const trackTop = y + 7.5;
|
|
125
|
+
fillRect(ctx, TRACK_X, trackTop, trackW, 6, 3, variant.track, variant.trackAlpha);
|
|
126
|
+
const pct = Math.max(0, Math.min(100, row.pct));
|
|
127
|
+
if (pct > 0) {
|
|
128
|
+
const fillW = Math.max(6, (trackW * pct) / 100);
|
|
129
|
+
fillRect(ctx, TRACK_X, trackTop, fillW, 6, 3, color, 1);
|
|
130
|
+
}
|
|
131
|
+
const pctText = `${Math.round(pct)}%`;
|
|
132
|
+
const pctW = textWidthPt(ctx, "b24", pctText);
|
|
133
|
+
drawText(ctx, "b24", pctText, RIGHT_EDGE - RESET_COL_W - COL_GAP - pctW, baseline, color);
|
|
134
|
+
const resetW = textWidthPt(ctx, "r22", row.resetLabel);
|
|
135
|
+
drawText(ctx, "r22", row.resetLabel, RIGHT_EDGE - resetW, baseline, variant.muted);
|
|
136
|
+
y += ROW_H;
|
|
137
|
+
}
|
|
138
|
+
if (block.note) {
|
|
139
|
+
drawText(ctx, "r24", block.note, PAD_X, y + 14, variant.muted);
|
|
140
|
+
if (block.updated) {
|
|
141
|
+
const updatedW = textWidthPt(ctx, "r22", block.updated);
|
|
142
|
+
drawText(ctx, "r22", block.updated, RIGHT_EDGE - updatedW, y + 14, variant.msg);
|
|
143
|
+
}
|
|
144
|
+
y += ROW_H;
|
|
145
|
+
}
|
|
146
|
+
if (block.message) {
|
|
147
|
+
const baseline = y + MSG_GAP + 11;
|
|
148
|
+
let messageMaxPt = W - PAD_X * 2;
|
|
149
|
+
if (block.updated) {
|
|
150
|
+
const updatedW = textWidthPt(ctx, "r22", block.updated);
|
|
151
|
+
drawText(ctx, "r22", block.updated, RIGHT_EDGE - updatedW, baseline, variant.msg);
|
|
152
|
+
messageMaxPt -= updatedW + 12;
|
|
153
|
+
}
|
|
154
|
+
const fitted = ellipsize(style(ctx, "r22"), block.message, messageMaxPt * ctx.scale);
|
|
155
|
+
drawText(ctx, "r22", fitted, PAD_X, baseline, variant.msg);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function drawCard(payload, glyphs, mode) {
|
|
159
|
+
const variant = mode === "dark" ? DARK : LIGHT;
|
|
160
|
+
const heightPt = cardHeight(payload);
|
|
161
|
+
const s = payload.scale || 2;
|
|
162
|
+
const canvas = new Canvas(Math.round(W * s), Math.round(heightPt * s));
|
|
163
|
+
const ctx = { canvas, glyphs, scale: s, heightPt };
|
|
164
|
+
const titleEnd = drawText(ctx, "b26", "Coding Usage Bar", PAD_X, TOP_PAD + 14, variant.text);
|
|
165
|
+
drawPill(ctx, titleEnd + 8, TOP_PAD + 1, payload.profile, variant.muted);
|
|
166
|
+
const updatedW = textWidthPt(ctx, "r22", payload.updatedLabel);
|
|
167
|
+
drawText(ctx, "r22", payload.updatedLabel, RIGHT_EDGE - updatedW, TOP_PAD + 13, variant.muted);
|
|
168
|
+
let top = TOP_PAD + HEADER_H + HEADER_GAP;
|
|
169
|
+
payload.providers.forEach((block, idx) => {
|
|
170
|
+
drawProviderBlock(ctx, block, top, mode, variant);
|
|
171
|
+
top += blockHeight(block);
|
|
172
|
+
if (idx < payload.providers.length - 1) {
|
|
173
|
+
fillRect(ctx, PAD_X, top, W - PAD_X * 2, 0.5, 0, variant.sep, variant.sepAlpha * 2);
|
|
174
|
+
top += 1;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
image: canvas.toPNGBase64(),
|
|
179
|
+
width: W,
|
|
180
|
+
height: Math.round(heightPt),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// Renders a single appearance variant: the caller picks light or dark from
|
|
184
|
+
// the current system appearance (see systemPrefersDark in menubar.ts) instead
|
|
185
|
+
// of shipping a SwiftBar light,dark pair, whose selection is broken in
|
|
186
|
+
// several SwiftBar versions.
|
|
187
|
+
export function renderUsageCard(payload, glyphs, mode) {
|
|
188
|
+
return drawCard(payload, glyphs, mode);
|
|
189
|
+
}
|
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;
|
package/dist/notifier.js
CHANGED
|
@@ -6,6 +6,14 @@ import { readJsonFile, writeJsonAtomic } from "./fs-util.js";
|
|
|
6
6
|
import { buildPaths, installedAssetPath } from "./paths.js";
|
|
7
7
|
import { formatDurationUntil } from "./format.js";
|
|
8
8
|
import { writeNotificationCard } from "./card.js";
|
|
9
|
+
// State is keyed by provider. Legacy records were keyed `provider:state` and
|
|
10
|
+
// re-notified on a cooldown, which spammed osascript spawns all day; dropping
|
|
11
|
+
// those keys makes each provider re-notify once after upgrade, then follow the
|
|
12
|
+
// transition-based rules.
|
|
13
|
+
function readNotificationState(paths) {
|
|
14
|
+
const raw = readJsonFile(paths.notificationStateFile) ?? {};
|
|
15
|
+
return Object.fromEntries(Object.entries(raw).filter(([key]) => !key.includes(":")));
|
|
16
|
+
}
|
|
9
17
|
export function commandExists(command) {
|
|
10
18
|
const result = spawnSync("/bin/sh", ["-c", `command -v ${command}`], {
|
|
11
19
|
stdio: "ignore",
|
|
@@ -24,33 +32,56 @@ export function notificationBackend() {
|
|
|
24
32
|
function cooldownMinutes(profile) {
|
|
25
33
|
return profile === "high" ? 15 : 30;
|
|
26
34
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
// Notify only on a state transition or when the 5h window resets while the
|
|
36
|
+
// alert persists — never on a periodic cooldown. The cooldown only acts as a
|
|
37
|
+
// per-provider floor between notifications so a flapping state cannot spam.
|
|
30
38
|
export function shouldNotify(analysis, paths = buildPaths(), now = new Date()) {
|
|
31
39
|
if (analysis.state === "RAW" || analysis.state === "ON_TRACK") {
|
|
32
40
|
return false;
|
|
33
41
|
}
|
|
34
|
-
const
|
|
35
|
-
const key = notificationKey(analysis);
|
|
36
|
-
const previous = state[key];
|
|
37
|
-
const resetAt = analysis.fiveHour?.resetsAt;
|
|
42
|
+
const previous = readNotificationState(paths)[analysis.provider];
|
|
38
43
|
if (!previous) {
|
|
39
44
|
return true;
|
|
40
45
|
}
|
|
41
|
-
if (previous.
|
|
46
|
+
if (previous.state === analysis.state) {
|
|
47
|
+
const resetAt = analysis.fiveHour?.resetsAt;
|
|
48
|
+
const sameWindow = !previous.resetAt || !resetAt || previous.resetAt === resetAt;
|
|
49
|
+
if (sameWindow) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!previous.notifiedAt) {
|
|
42
54
|
return true;
|
|
43
55
|
}
|
|
44
56
|
const elapsedMinutes = (now.getTime() - Date.parse(previous.notifiedAt)) / 60_000;
|
|
45
57
|
return elapsedMinutes >= cooldownMinutes(analysis.profile);
|
|
46
58
|
}
|
|
47
59
|
export function markNotified(analysis, paths = buildPaths(), now = new Date()) {
|
|
48
|
-
const state =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
const state = readNotificationState(paths);
|
|
61
|
+
writeJsonAtomic(paths.notificationStateFile, {
|
|
62
|
+
...state,
|
|
63
|
+
[analysis.provider]: {
|
|
64
|
+
state: analysis.state,
|
|
65
|
+
notifiedAt: now.toISOString(),
|
|
66
|
+
resetAt: analysis.fiveHour?.resetsAt,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Record recovery so the next entry into an alert state counts as a transition
|
|
71
|
+
// and notifies again. Keeps notifiedAt so the anti-flap floor still applies.
|
|
72
|
+
export function markRecovered(analysis, paths = buildPaths()) {
|
|
73
|
+
if (analysis.state !== "ON_TRACK") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const state = readNotificationState(paths);
|
|
77
|
+
const previous = state[analysis.provider];
|
|
78
|
+
if (!previous || previous.state === "ON_TRACK") {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
writeJsonAtomic(paths.notificationStateFile, {
|
|
82
|
+
...state,
|
|
83
|
+
[analysis.provider]: { ...previous, state: "ON_TRACK" },
|
|
84
|
+
});
|
|
54
85
|
}
|
|
55
86
|
function notificationText(analysis) {
|
|
56
87
|
const provider = analysis.provider === "claude" ? "Claude" : analysis.provider === "glm" ? "GLM" : analysis.provider === "minimax" ? "MiniMax" : analysis.provider === "deepseek" ? "DeepSeek" : analysis.provider === "kimi" ? "Kimi" : "Codex";
|
package/dist/paths.js
CHANGED
|
@@ -22,6 +22,7 @@ export function buildPaths(homeDir = os.homedir()) {
|
|
|
22
22
|
launchAgentFile: path.join(homeDir, "Library", "LaunchAgents", "com.duying.coding-usage-bar.plist"),
|
|
23
23
|
claudeSettingsFile: path.join(homeDir, ".claude", "settings.json"),
|
|
24
24
|
claudeStatusLineScript: path.join(stateDir, "claude", "statusline.sh"),
|
|
25
|
+
glyphsDir: path.join(stateDir, "glyphs"),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
28
|
export function installedAssetPath(homeDir, assetName) {
|
package/dist/types.d.ts
CHANGED