coding-usage-bar 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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<{
@@ -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
- const sy = H - sPngY; // back to AppKit y-up
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, renderDropdownBars } from "./menubar-render.js";
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
- const dropdownBarCache = new Map();
70
- function renderDropdownBarImages(bars) {
71
- if (bars.length === 0) {
72
- return [];
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
- const payload = {
75
- bars,
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
- const result = renderDropdownBars(payload);
90
- dropdownBarCache.set(cacheKey, result);
91
- return result.images.map((img) => ({ image: img, width: result.width, height: result.height }));
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
- dropdownBarCache.set(cacheKey, null);
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
- function titleImageValue(providers) {
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 value = images.light?.image && images.dark?.image && images.light.width && images.light.height
441
+ const chosen = prefersDark ? images.dark : images.light;
442
+ const value = chosen?.image && chosen.width && chosen.height
415
443
  ? {
416
- image: `${images.light.image},${images.dark.image}`,
417
- width: images.light.width,
418
- height: images.light.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, barImage) {
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 titleImage = titleImageValue(providers);
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
- line("Coding Usage Bar", {
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
- const barDataList = [];
584
- for (const item of providers) {
585
- if (item.usage.balance && item.usage.windows.length === 0) {
586
- continue;
587
- }
588
- const [lc, dc] = (item.usage.blocked ? MUTED_COLOR : STATE_COLOR[item.analysis.state]).split(",");
589
- const f = windowByName(item, "five_hour");
590
- const s = windowByName(item, "seven_day");
591
- if (f) {
592
- barDataList.push({ pct: Math.round(f.usedPercent), lightColor: lc, darkColor: dc });
593
- }
594
- if (s) {
595
- barDataList.push({ pct: Math.round(s.usedPercent), lightColor: lc, darkColor: dc });
596
- }
597
- }
598
- const dropdownBars = renderDropdownBarImages(barDataList);
599
- let barIdx = 0;
600
- for (const item of providers) {
601
- const color = STATE_COLOR[item.analysis.state];
602
- const five = windowByName(item, "five_hour");
603
- const seven = windowByName(item, "seven_day");
604
- if (item.usage.balance && item.usage.windows.length === 0) {
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: balanceText,
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
- for (const issue of snapshot.issues) {
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(muted(issue.message));
796
+ lines.push(line(issue.message, { color: MUTED_COLOR, size: 12, length: 84 }));
651
797
  }
652
- if (snapshot.issues.length > 0) {
798
+ if (visibleIssues.length > 0) {
653
799
  lines.push("---");
654
800
  }
655
801
  lines.push(line(compact ? "Expand" : "Collapse", toggleParams));
@@ -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;