coding-usage-bar 0.3.0 → 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/kimi.js +7 -2
- 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) {
|
package/dist/kimi.js
CHANGED
|
@@ -16,11 +16,16 @@ function asNumber(value) {
|
|
|
16
16
|
}
|
|
17
17
|
// Kimi reports limit/used/remaining per window. Prefer the used/limit ratio;
|
|
18
18
|
// when limit is 0 or missing, derive the total from used + remaining instead of
|
|
19
|
-
// falling back to 0% while a real signal exists.
|
|
19
|
+
// falling back to 0% while a real signal exists. Kimi also omits zero-valued
|
|
20
|
+
// fields entirely (proto3 JSON): a fresh window with no usage reports only
|
|
21
|
+
// limit + remaining, so derive used from limit - remaining instead of failing.
|
|
20
22
|
function percentFromDetail(detail) {
|
|
21
23
|
const limit = asNumber(detail.limit);
|
|
22
|
-
|
|
24
|
+
let used = asNumber(detail.used);
|
|
23
25
|
const remaining = asNumber(detail.remaining);
|
|
26
|
+
if (used === null && limit !== null && remaining !== null) {
|
|
27
|
+
used = Math.max(0, limit - remaining);
|
|
28
|
+
}
|
|
24
29
|
if (limit !== null && limit > 0 && used !== null) {
|
|
25
30
|
return Math.max(0, Math.min(100, (used / limit) * 100));
|
|
26
31
|
}
|
|
@@ -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<{
|