@cad0p/pi-usage 0.11.4 → 0.11.5-20261103.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.
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Pi quota line — read-only quota for the providers Pi already owns.
3
3
  *
4
- * Surface: nothing at session start. Bare /usage shows the panel until the next
5
- * prompt or a second /usage. /usage box, /usage line or /usage panel pins
6
- * a surface above the editor, /usage status enables the footer, and /usage hide
7
- * removes it. No timer or post-turn refresh runs until a surface is on.
4
+ * Surface: nothing at session start. Bare /usage opens a fullscreen overlay that
5
+ * takes the keyboard, scrolls, folds a section per provider and closes on Esc or
6
+ * q; nothing stays on screen behind it. /usage box, /usage line or /usage panel
7
+ * pins a surface above the editor, /usage status enables the footer, and
8
+ * /usage hide removes it. No timer or post-turn refresh runs until a surface is on.
8
9
  *
9
10
  * Design rules:
10
11
  * - the numbers are **used**, not remaining, so the line answers "how much have
@@ -23,7 +24,7 @@
23
24
  */
24
25
 
25
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
- import { existsSync } from "node:fs";
27
+ import { existsSync, readFileSync } from "node:fs";
27
28
  import { homedir } from "node:os";
28
29
  import { dirname, join } from "node:path";
29
30
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -83,6 +84,8 @@ type LocalAccountStats = {
83
84
  todayPrompts?: number;
84
85
  totalPrompts?: number;
85
86
  totalTokens?: number;
87
+ /** The account's own model split for today; absent from an older report. */
88
+ todayTokensByModel?: Record<string, number>;
86
89
  };
87
90
 
88
91
  /**
@@ -124,11 +127,17 @@ type Theme = {
124
127
  /** Only the UI surface this extension uses. */
125
128
  type UiContext = {
126
129
  hasUI: boolean;
130
+ /** Pi's run mode; `custom` components only exist in the TUI. */
131
+ mode?: string;
127
132
  ui: {
128
133
  theme: Theme;
129
134
  setStatus: (key: string, value: string | undefined) => void;
130
135
  setWidget: (key: string, value: any, options?: { placement?: string }) => void;
131
136
  notify: (message: string, type?: string) => void;
137
+ custom?: (
138
+ factory: (tui: any, theme: Theme, keybindings: any, done: (result?: unknown) => void) => any,
139
+ options?: { overlay?: boolean; overlayOptions?: Record<string, unknown> },
140
+ ) => Promise<unknown>;
132
141
  };
133
142
  };
134
143
 
@@ -550,44 +559,103 @@ function clipToWidth(line: string, width: number): string {
550
559
  return out;
551
560
  }
552
561
 
562
+ /** One visible character with the SGR runs in front of it and the style it inherits. */
563
+ type StyledChar = { raw: string; char: string; style: string };
564
+
565
+ /**
566
+ * Split a themed line into visible characters, folding each SGR run into the
567
+ * character that follows it and remembering the style open at that point.
568
+ * Wrapping needs both: a continuation row must re-open the style it inherits,
569
+ * and every produced row must close whatever it left open.
570
+ */
571
+ function styledChars(line: string): { chars: StyledChar[]; trailing: string } {
572
+ const chars: StyledChar[] = [];
573
+ let pending = "";
574
+ let style = "";
575
+ for (let index = 0; index < line.length; ) {
576
+ const sgr = /^\u001b\[[0-9;]*m/.exec(line.slice(index));
577
+ if (sgr) {
578
+ pending += sgr[0];
579
+ style = sgr[0] === "\u001b[0m" ? "" : `${style}${sgr[0]}`;
580
+ index += sgr[0].length;
581
+ continue;
582
+ }
583
+ const char = String.fromCodePoint(line.codePointAt(index) as number);
584
+ chars.push({ raw: `${pending}${char}`, char, style });
585
+ pending = "";
586
+ index += char.length;
587
+ }
588
+ return { chars, trailing: pending };
589
+ }
590
+
591
+ /**
592
+ * Wrap one themed line to `width` columns, breaking at its " · " separators.
593
+ *
594
+ * A hard clip used to cut a row mid-token at 64 columns, which loses the datum
595
+ * that matters most: "(18d)" is when the month resets, and "token exp" is the
596
+ * state of the credential. So a row that does not fit wraps instead — the first
597
+ * row keeps the line's own indent, every continuation is indented by the line's
598
+ * leading spaces (or `hanging`), and no cell is ever dropped. A single cell
599
+ * wider than the terminal is the one case that still clips: there is nowhere to
600
+ * break it.
601
+ */
602
+ function wrapLine(line: string, width: number, hanging?: string): string[] {
603
+ const limit = Math.max(1, Math.floor(width));
604
+ if (visibleLength(line) <= limit) return [line];
605
+ const { chars, trailing } = styledChars(line);
606
+ if (chars.length === 0) return [line];
607
+ const visible = chars.map((item) => item.char).join("");
608
+ // A break sits on the space in front of " · ", so neither row starts with a
609
+ // dangling middle dot.
610
+ const breaks: number[] = [];
611
+ const spaces: number[] = [];
612
+ for (let index = 0; index + 2 < visible.length; index += 1) {
613
+ if (visible[index] === " " && visible[index + 1] === "·" && visible[index + 2] === " ") breaks.push(index);
614
+ }
615
+ for (let index = 0; index < visible.length; index += 1) if (visible[index] === " ") spaces.push(index);
616
+ const indent = hanging ?? /^ */.exec(visible)?.[0] ?? "";
617
+ const row = (from: number, to: number, prefix: string, extra = ""): string => {
618
+ // A wrapped row must not end in the padding the break was taken on.
619
+ const text = `${prefix}${chars.slice(from, to).map((item) => item.raw).join("")}${extra}`.replace(/ +(?=(\u001b\[[0-9;]*m)*$)/, "");
620
+ return /\u001b\[/.test(text) ? `${text}\u001b[0m` : text;
621
+ };
622
+
623
+ const lines: string[] = [];
624
+ let start = 0;
625
+ while (start < chars.length) {
626
+ const prefix = start === 0 ? "" : chars[start].style === "" ? indent : `${indent}${chars[start].style}`;
627
+ const fits = start + Math.max(1, limit - visibleLength(prefix));
628
+ if (fits >= chars.length) {
629
+ lines.push(row(start, chars.length, prefix, trailing));
630
+ break;
631
+ }
632
+ // A " · " boundary is the natural break; a word boundary is the fallback, so
633
+ // a long warning wraps between words instead of mid-token; a single word
634
+ // wider than the pane is the only case left to hard-cut.
635
+ const breakAt = breaks.filter((index) => index > start && index <= fits).pop();
636
+ const spaceAt = breakAt === undefined ? spaces.filter((index) => index > start && index <= fits).pop() : undefined;
637
+ const cut = breakAt ?? spaceAt ?? fits;
638
+ lines.push(row(start, cut, prefix));
639
+ start = breakAt === undefined ? (spaceAt === undefined ? cut : cut + 1) : cut + 3;
640
+ }
641
+ return lines.length > 0 ? lines : [line];
642
+ }
643
+
553
644
  /**
554
645
  * The panel as a component rather than a string array: Pi caps string-array
555
646
  * widgets at ten lines, and a multi-account family alone can exceed that.
647
+ *
648
+ * A line that does not fit wraps at its separators instead of being cut: at 64
649
+ * columns a hard clip used to lose the half of a row that says when the month
650
+ * resets, which is exactly the datum the row exists for.
556
651
  */
557
652
  function panelComponent(lines: string[]) {
558
653
  return () => ({
559
- render: (width: number) => lines.map((line) => ` ${clipToWidth(line, Math.max(0, width - 1))}`),
654
+ render: (width: number) => lines.flatMap((line) => wrapLine(line, Math.max(0, width - 1))).map((line) => ` ${line}`),
560
655
  invalidate() {},
561
656
  });
562
657
  }
563
658
 
564
- /**
565
- * The one-line notify summary. A multi-account family reports how many accounts
566
- * answer and the account with the most room, not whichever key happens to be #1.
567
- */
568
- function summarize(report: QuotaReport): string {
569
- const active = report.providers.filter((provider) => provider.notConfigured !== true);
570
- return firstPerFamily(active)
571
- .map((provider) => {
572
- const name = SHORT_NAME[provider.family] ?? provider.family;
573
- if (isMultiAccount(active, provider)) {
574
- const accounts = active.filter((other) => other.family === provider.family);
575
- const scored = accounts
576
- .filter((account) => account.ok)
577
- .map((account) => ({ account, used: usedOf(bindingWindow(account)) }))
578
- .filter((entry): entry is { account: QuotaProvider; used: number } => entry.used !== null);
579
- const counted = `${accounts.filter((account) => account.ok).length}/${accounts.length} ok`;
580
- if (scored.length === 0) return `${name}: ${counted}`;
581
- const lowest = scored.reduce((best, entry) => (entry.used < best.used ? entry : best));
582
- return `${name}: ${counted} · lowest ${Math.round(lowest.used)}% (${lowest.account.account})`;
583
- }
584
- if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
585
- const used = usedOf(headline(provider));
586
- return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
587
- })
588
- .join(" · ");
589
- }
590
-
591
659
  /** Window names for the one-line account rows (same words as the Omarchy panel). */
592
660
  const WINDOW_TAG: Record<string, string> = { "5h": "session", weekly: "week", monthly: "month" };
593
661
 
@@ -690,11 +758,29 @@ function accountReason(error: string | null | undefined): string {
690
758
  return errorCaption(error);
691
759
  }
692
760
 
693
- /** `#N` accounts of one family, i.e. a family Pi holds several credentials for. */
761
+ /**
762
+ * The slot label of an account: "" for the base auth.json entry (it has no
763
+ * number, so no surface may invent one) and "#<slot>" for a real sibling. A
764
+ * report cached before the fix still says "#1" for the base entry.
765
+ */
766
+ function accountLabel(account: string | null | undefined): string {
767
+ const text = String(account ?? "");
768
+ if (text === "#1") return "";
769
+ return /^#\d+$/.test(text) ? text : text;
770
+ }
771
+
772
+ /** The per-account token row, tolerating the old "#1" key in a cached scan. */
773
+ function accountStats(stats: LocalFamilyStats | undefined, label: string): LocalAccountStats | undefined {
774
+ if (!stats?.byAccount) return undefined;
775
+ return stats.byAccount[label] ?? (label === "" ? stats.byAccount["#1"] : undefined);
776
+ }
777
+
778
+ /**
779
+ * A family Pi holds several credentials for: one section and one line per
780
+ * account, whatever the account labels look like.
781
+ */
694
782
  function isMultiAccount(providers: QuotaProvider[], provider: QuotaProvider): boolean {
695
- return typeof provider.account === "string"
696
- && provider.account.startsWith("#")
697
- && providers.filter((other) => other.family === provider.family).length > 1;
783
+ return providers.filter((other) => other.family === provider.family).length > 1;
698
784
  }
699
785
 
700
786
  /**
@@ -708,20 +794,23 @@ function renderAccountRows(accounts: QuotaProvider[], theme: Theme, stats?: Loca
708
794
  const healthy = accounts.filter((account) => account.ok).length;
709
795
  const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
710
796
  const lines = [header];
711
- const width = Math.max(...accounts.map((account) => account.account.length));
712
- for (const account of accounts) {
713
- const label = theme.fg("dim", account.account.padEnd(width));
797
+ // The base entry has no number: its label column is blank but padded to the
798
+ // widest sibling, so the percentage columns still line up.
799
+ const labels = accounts.map((account) => accountLabel(account.account));
800
+ const width = Math.max(...labels.map((label) => label.length));
801
+ accounts.forEach((account, index) => {
802
+ const label = theme.fg("dim", labels[index].padEnd(width));
714
803
  if (!account.ok) {
715
804
  const kind = errorKind(account.error);
716
805
  const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
717
806
  lines.push(` ${label} ${theme.fg(color, errorGlyph(account.error))} ${theme.fg("warning", accountReason(account.error))}`);
718
- continue;
807
+ return;
719
808
  }
720
809
  const window = bindingWindow(account);
721
810
  const used = usedOf(window);
722
811
  if (window === null || used === null) {
723
812
  lines.push(` ${label} ${theme.fg("dim", "no rate-limit windows reported")}`);
724
- continue;
813
+ return;
725
814
  }
726
815
  // Every window on one line, each in its own band colour; the leading glyph
727
816
  // is the fullest window's, so the column still sorts the accounts by eye.
@@ -733,85 +822,1345 @@ function renderAccountRows(accounts: QuotaProvider[], theme: Theme, stats?: Loca
733
822
  const reset = each.resetsInSec === null ? "" : ` ${theme.fg("dim", `(${shortDuration(each.resetsInSec)})`.padEnd(5))}`;
734
823
  return `${theme.fg("dim", WINDOW_TAG[each.id] ?? each.id)} ${paint(bandFor(pct).color, `${Math.round(pct)}%`.padStart(4))}${reset}`;
735
824
  });
736
- const today = Number(stats?.byAccount?.[account.account]?.todayTokens) || 0;
825
+ const today = Number(accountStats(stats, labels[index])?.todayTokens) || 0;
737
826
  const tokens = today > 0 ? ` · ${formatTokens(today)} today` : "";
738
827
  lines.push(` ${label} ${paint(band.color, band.glyph)} ${cells.join(theme.fg("dim", " · "))}${theme.fg("dim", tokens)}`);
739
- }
828
+ });
740
829
  return lines;
741
830
  }
742
831
 
743
- function renderPanel(report: QuotaReport, theme: Theme): string[] {
832
+ /**
833
+ * One provider block of the panel: the header painted with the provider name
834
+ * and every window, account and token row that belongs to it. The overlay
835
+ * folds on these blocks; the pinned widget renders them flat, unchanged.
836
+ */
837
+ type PanelSection = { key: string; header: string; body: string[] };
838
+
839
+ /** The provider block of a single-account provider: its header plus its detail. */
840
+ function renderProviderLines(provider: QuotaProvider, theme: Theme, localStats: LocalFamilyStats | undefined, partialScan: boolean): string[] {
744
841
  const lines: string[] = [];
842
+ const labelWidth = Math.max(6, ...provider.windows.map((window) => String(window.label ?? "").length));
843
+ const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
844
+ const account = accountLabel(provider.account);
845
+ const meta: string[] = account === "" ? [] : [account];
846
+ if (provider.plan) meta.push(`plan ${provider.plan}`);
847
+ // Two stores can back Claude, and this is the one place that says which.
848
+ if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
849
+ if (provider.expiresInMin !== null && provider.expiresInMin <= 120) {
850
+ meta.push(
851
+ provider.expiresInMin < 0
852
+ ? theme.fg("error", "token expired")
853
+ : theme.fg("dim", `token ${provider.expiresInMin}m`),
854
+ );
855
+ }
856
+ lines.push(meta.length > 0 ? `${name} ${theme.fg("dim", `· ${meta.join(" · ")}`)}` : name);
857
+
858
+ if (!provider.ok) {
859
+ const kind = errorKind(provider.error);
860
+ const glyph = errorGlyph(provider.error);
861
+ const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
862
+ lines.push(` ${theme.fg(color, glyph)} ${theme.fg("warning", provider.note ?? errorCaption(provider.error))}`);
863
+ if (isRecoverableError(provider.error) && provider.note) lines.push(` ${theme.fg("warning", provider.note)}`);
864
+ } else if (provider.windows.length === 0) {
865
+ if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
866
+ lines.push(` ${theme.fg("dim", "no rate-limit windows reported")}`);
867
+ } else {
868
+ if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
869
+ for (const window of provider.windows) {
870
+ const used = usedOf(window);
871
+ const label = theme.fg("dim", window.label.padEnd(labelWidth));
872
+ if (used === null) {
873
+ lines.push(` ${label} ${theme.fg("dim", window.note ?? "no percentage reported")}`);
874
+ continue;
875
+ }
876
+ const band = bandFor(used);
877
+ const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
878
+ lines.push(
879
+ ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
880
+ `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
881
+ `${theme.fg("dim", "used")}${reset}`,
882
+ );
883
+ }
884
+ }
885
+
886
+ // Local token stats survive a failed quota probe: the credential may be
887
+ // rejected while the tokens already burned are still a fact on disk.
888
+ lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
889
+ return lines;
890
+ }
891
+
892
+ /**
893
+ * Split the panel into its foldable provider sections and the lines below them
894
+ * (byline, unconfigured providers, warnings). Bare `/usage` folds these; the
895
+ * pinned `/usage panel` flattens them back into the same list as before.
896
+ */
897
+ function renderPanelLayout(report: QuotaReport, theme: Theme): { sections: PanelSection[]; tail: string[] } {
898
+ const sections: PanelSection[] = [];
745
899
  const active = report.providers.filter((provider) => provider.notConfigured !== true);
746
900
  const unconfigured = report.providers.filter((provider) => provider.notConfigured === true);
747
901
  const renderedFamilies = new Set<string>();
748
-
749
902
  const partialScan = report.localStatsMeta?.partial === true;
903
+
750
904
  for (const provider of active) {
751
905
  const localStats = report.localStats?.[provider.family];
752
906
  const labelWidth = Math.max(6, ...provider.windows.map((window) => String(window.label ?? "").length));
753
907
  if (isMultiAccount(active, provider)) {
754
908
  if (renderedFamilies.has(provider.family)) continue;
755
909
  renderedFamilies.add(provider.family);
756
- lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme, localStats));
757
- lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
910
+ const rows = renderAccountRows(active.filter((other) => other.family === provider.family), theme, localStats);
911
+ sections.push({
912
+ key: `${provider.family}`,
913
+ header: rows[0] ?? paint(BRAND[provider.family] ?? "#8B949E", provider.label),
914
+ body: [...rows.slice(1), ...renderTokenRows(localStats, labelWidth, theme, partialScan)],
915
+ });
758
916
  continue;
759
917
  }
760
- const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
761
- const meta: string[] = [provider.account];
762
- if (provider.plan) meta.push(`plan ${provider.plan}`);
763
- // Two stores can back Claude, and this is the one place that says which.
764
- if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
765
- if (provider.expiresInMin !== null && provider.expiresInMin <= 120) {
766
- meta.push(
767
- provider.expiresInMin < 0
768
- ? theme.fg("error", "token expired")
769
- : theme.fg("dim", `token ${provider.expiresInMin}m`),
770
- );
918
+ const lines = renderProviderLines(provider, theme, localStats, partialScan);
919
+ sections.push({ key: `${provider.family}:${provider.account}`, header: lines[0] ?? "", body: lines.slice(1) });
920
+ }
921
+
922
+ const tail: string[] = [];
923
+ // No timestamp here: the overlay header already says when the report landed
924
+ // ("updated HH:MM"), and a second format for the same fact reads as a
925
+ // different number to check. The pinned panel prints this line alone.
926
+ tail.push(theme.fg("dim", "used % · read-only"));
927
+ if (unconfigured.length > 0) {
928
+ const names = unconfigured.map((p) => SHORT_NAME[p.family] ?? p.family).join(", ");
929
+ tail.push(theme.fg("dim", `not configured in Pi: ${names}`));
930
+ }
931
+ for (const warning of report.warnings.slice(0, 3)) {
932
+ tail.push(theme.fg("warning", `warn: ${warning}`));
933
+ }
934
+ return { sections, tail };
935
+ }
936
+
937
+ /** The flat panel: every section, then the footer, unconfigured names, warnings. */
938
+ function renderPanel(report: QuotaReport, theme: Theme): string[] {
939
+ const { sections, tail } = renderPanelLayout(report, theme);
940
+ return [...sections.flatMap((section) => [section.header, ...section.body]), ...tail];
941
+ }
942
+
943
+ /**
944
+ * Raw input sequences for the keys the overlay turns into actions. The
945
+ * extension ships as one file with no runtime dependencies, so this mirrors
946
+ * the subset of pi-tui's matchesKey() the overlay needs: arrows, paging and
947
+ * home/end in the encodings terminals and tmux actually send.
948
+ */
949
+ const KEY_SEQUENCES: Record<string, string[]> = {
950
+ up: ["\u001b[A", "\u001bOA"],
951
+ down: ["\u001b[B", "\u001bOB"],
952
+ left: ["\u001b[D", "\u001bOD"],
953
+ right: ["\u001b[C", "\u001bOC"],
954
+ pageUp: ["\u001b[5~", "\u001b[[5~"],
955
+ pageDown: ["\u001b[6~", "\u001b[[6~"],
956
+ home: ["\u001b[H", "\u001bOH", "\u001b[1~", "\u001b[7~"],
957
+ end: ["\u001b[F", "\u001bOF", "\u001b[4~", "\u001b[8~"],
958
+ };
959
+
960
+ function isKey(data: string, name: string): boolean {
961
+ return (KEY_SEQUENCES[name] ?? []).includes(data);
962
+ }
963
+
964
+ /** Pi reports the terminal height as a number; fall back to a usable frame. */
965
+ function terminalRows(value: number | undefined): number {
966
+ return Number.isFinite(value) ? Math.max(1, Math.floor(value as number)) : 24;
967
+ }
968
+
969
+ function clampScroll(value: number, max: number): number {
970
+ return Math.max(0, Math.min(value, max));
971
+ }
972
+
973
+ /** One line with its label left and its status right, clipped to the width. */
974
+ function spreadLine(left: string, right: string, width: number): string {
975
+ if (right === "") return clipToWidth(left, width);
976
+ const rightText = clipToWidth(right, width);
977
+ const leftText = clipToWidth(left, Math.max(0, width - visibleLength(rightText) - 1));
978
+ const gap = Math.max(0, width - visibleLength(leftText) - visibleLength(rightText));
979
+ return `${leftText}${" ".repeat(gap)}${rightText}`;
980
+ }
981
+
982
+ /** One content row of the overlay, with the section it belongs to. */
983
+ type OverlayRow = { text: string; section: number; header: boolean };
984
+
985
+ /** One month of the durable history, as `piquota stats --json` returns it. */
986
+ type HistoryPayload = {
987
+ generatedAt?: string;
988
+ months?: string[];
989
+ quarantined?: string[];
990
+ month?: string;
991
+ days?: Record<string, Record<string, HistoryFamilyDay>>;
992
+ totals?: HistoryTotals | null;
993
+ notes?: string[];
994
+ };
995
+
996
+ type HistoryFamilyDay = {
997
+ sessions?: number;
998
+ tokens?: number;
999
+ prompts?: number;
1000
+ slots?: Record<string, HistorySlotDay>;
1001
+ };
1002
+
1003
+ type HistorySlotDay = {
1004
+ sessions?: number;
1005
+ tokens?: number;
1006
+ prompts?: number;
1007
+ models?: Record<string, { tokens?: number; messages?: number }>;
1008
+ };
1009
+
1010
+ type HistoryTotals = {
1011
+ lastFrozenDay?: string;
1012
+ families?: Record<string, HistoryTotalsFamily>;
1013
+ };
1014
+
1015
+ type HistoryTotalsModel = {
1016
+ input?: number;
1017
+ output?: number;
1018
+ cacheRead?: number;
1019
+ cacheWrite?: number;
1020
+ };
1021
+
1022
+ type HistoryTotalsFamily = {
1023
+ firstDay?: string;
1024
+ activeDates?: string[];
1025
+ totalPrompts?: number;
1026
+ totalSessions?: number;
1027
+ modelUsage?: Record<string, HistoryTotalsModel>;
1028
+ accounts?: Record<string, { totalPrompts?: number; totalSessions?: number; modelUsage?: Record<string, HistoryTotalsModel> }>;
1029
+ };
1030
+
1031
+ /** One family's month, aggregated from the payload and the live report. */
1032
+ type HistoryFamily = {
1033
+ tokens: number;
1034
+ prompts: number;
1035
+ days: Map<string, number>;
1036
+ models: Map<string, number>;
1037
+ accounts: Map<string, { tokens: number; prompts: number; models: Map<string, number> }>;
1038
+ allTime: number;
1039
+ allTimePrompts: number;
1040
+ allTimeAccounts: Map<string, { tokens: number; prompts: number }>;
1041
+ /** The live day's delta, added to both the month and the all-time figures. */
1042
+ liveTokens: number;
1043
+ livePrompts: number;
1044
+ liveAccounts: Map<string, { tokens: number; prompts: number }>;
1045
+ };
1046
+
1047
+ /** An all-time figure, and whether the rollup behind it lags the month on screen. */
1048
+ type AllTimeFigure = { value: number; lags: boolean };
1049
+
1050
+ /**
1051
+ * The all-time figure for one scope. All time contains every frozen day, so it
1052
+ * can never be smaller than a month of it: a smaller rollup means totals.json is
1053
+ * behind the month file — a freeze whose totals write failed, a month restored
1054
+ * behind the rollup. That is printed, dimmed and with a note, never hidden: the
1055
+ * figure is the only answer to "how much has this burned ever", and dropping it
1056
+ * silently is a worse lie than a number with a caveat.
1057
+ */
1058
+ function allTimeFigure(month: number, allTime: number): AllTimeFigure {
1059
+ if (allTime <= 0) return { value: 0, lags: false };
1060
+ return { value: allTime, lags: allTime < month };
1061
+ }
1062
+
1063
+ /** The note printed with an all-time figure that cannot cover the month on screen. */
1064
+ const ALL_TIME_LAGS_NOTE = "all-time lags this month; run piquota stats --rebuild-totals";
1065
+
1066
+ /**
1067
+ * What outlives one overlay instance. Every bare `/usage` builds a fresh view,
1068
+ * so the folds and the page it was closed on live in the extension — for the pi
1069
+ * session only, and never on disk.
1070
+ */
1071
+ type OverlaySession = {
1072
+ expanded: Map<string, boolean>;
1073
+ view: "now" | "history";
1074
+ month: string | null;
1075
+ };
1076
+
1077
+ function newOverlaySession(): OverlaySession {
1078
+ return { expanded: new Map(), view: "now", month: null };
1079
+ }
1080
+
1081
+ /**
1082
+ * The History page's fetch state. "Not asked yet", "in flight" and "the last
1083
+ * fetch failed" are three different frames: the failed one must not also claim
1084
+ * to be loading.
1085
+ */
1086
+ type HistoryStatus =
1087
+ | { kind: "idle" }
1088
+ | { kind: "loading"; month: string | null }
1089
+ | { kind: "failed"; month: string | null; message: string };
1090
+
1091
+ /**
1092
+ * One id per `stats --json` request, for the whole pi process: a reopened overlay
1093
+ * issues the next id, so an answer to a closed view's request can never be taken
1094
+ * for an answer to its own.
1095
+ */
1096
+ let historyRequestSeq = 0;
1097
+
1098
+ type UsageOverlayOptions = {
1099
+ /** Close the overlay through the component's completion callback. */
1100
+ done: () => void;
1101
+ /** Ask the TUI for a frame after state changed. */
1102
+ requestRender: () => void;
1103
+ /** Current terminal height. */
1104
+ getRows: () => number;
1105
+ /** The newest report the extension holds, or null before the first one. */
1106
+ getReport: () => QuotaReport | null;
1107
+ /** The last refresh failure, shown as a line instead of thrown. */
1108
+ getError: () => string | null;
1109
+ isRefreshing: () => boolean;
1110
+ /** Run the same forced refresh `/usage` always ran, in the background. */
1111
+ refresh: () => void;
1112
+ /**
1113
+ * Ask the extension to run `piquota stats --json` for one month (null: newest).
1114
+ * The request id comes back with the answer, so a slow fetch cannot apply a
1115
+ * month the last keypress did not ask for.
1116
+ */
1117
+ loadHistory: (month: string | null, request: number) => void;
1118
+ };
1119
+
1120
+ /** Display names for a family the current report does not mention. */
1121
+ const FAMILY_LABEL: Record<string, string> = {
1122
+ claude: "Claude (Pi)",
1123
+ codex: "Codex (Pi)",
1124
+ antigravity: "Antigravity (Pi)",
1125
+ "opencode-go": "OpenCode Go (Pi)",
1126
+ "github-actions": "GitHub Actions",
1127
+ };
1128
+
1129
+ /**
1130
+ * The key legend. Every key the current view answers appears here; a pair a
1131
+ * view does not answer (month navigation outside History) is not listed. The
1132
+ * legend wraps instead of dropping pairs, so a 60-column terminal still shows
1133
+ * every key; the close key stays last.
1134
+ */
1135
+ const NOW_HINTS: ReadonlyArray<readonly [string, string]> = [
1136
+ ["↑↓/jk", "move"],
1137
+ ["enter/space/←→/hl", "fold"],
1138
+ ["pgup/pgdn", "page"],
1139
+ ["g/G home/end", "top/end"],
1140
+ ["wheel", "scroll"],
1141
+ ["r", "refresh"],
1142
+ ["tab/1/2", "history"],
1143
+ ["esc/q", "close"],
1144
+ ];
1145
+
1146
+ const HISTORY_HINTS: ReadonlyArray<readonly [string, string]> = [
1147
+ ["↑↓/jk", "move"],
1148
+ ["enter/space/h/l", "fold"],
1149
+ ["←/[", "prev month"],
1150
+ ["]/→", "next month"],
1151
+ ["pgup/pgdn", "page"],
1152
+ ["g/G home/end", "top/end"],
1153
+ ["wheel", "scroll"],
1154
+ ["r", "refresh"],
1155
+ ["tab/1/2", "now"],
1156
+ ["esc/q", "close"],
1157
+ ];
1158
+
1159
+ /** The report cache the CLI writes after every successful run. */
1160
+ function reportCachePath(): string {
1161
+ const cacheHome = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
1162
+ return join(cacheHome, "pi-quota", "usage.json");
1163
+ }
1164
+
1165
+ /**
1166
+ * The last report the CLI cached, read synchronously so the overlay can paint
1167
+ * on the same keystroke as /usage. Read-only, age is irrelevant for a first
1168
+ * frame, and a missing or malformed cache is simply "no cached report".
1169
+ */
1170
+ function readCachedReport(): QuotaReport | null {
1171
+ try {
1172
+ const parsed = JSON.parse(readFileSync(reportCachePath(), "utf-8"));
1173
+ const report = parsed?.report;
1174
+ if (!report || !Array.isArray(report.providers)) return null;
1175
+ // The cache stores absolute reset instants; the surfaces paint countdowns.
1176
+ const now = Date.now();
1177
+ for (const provider of report.providers) {
1178
+ for (const window of provider.windows ?? []) {
1179
+ if (!window.resetsAt) continue;
1180
+ const resetsAtMs = Date.parse(window.resetsAt);
1181
+ if (!Number.isNaN(resetsAtMs)) window.resetsInSec = Math.max(0, Math.round((resetsAtMs - now) / 1000));
1182
+ }
771
1183
  }
772
- lines.push(meta.length > 0 ? `${name} ${theme.fg("dim", `· ${meta.join(" · ")}`)}` : name);
1184
+ return report as QuotaReport;
1185
+ } catch {
1186
+ return null;
1187
+ }
1188
+ }
773
1189
 
774
- if (!provider.ok) {
775
- const kind = errorKind(provider.error);
776
- const glyph = errorGlyph(provider.error);
777
- const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
778
- lines.push(` ${theme.fg(color, glyph)} ${theme.fg("warning", provider.note ?? errorCaption(provider.error))}`);
779
- if (isRecoverableError(provider.error) && provider.note) lines.push(` ${theme.fg("warning", provider.note)}`);
780
- } else if (provider.windows.length === 0) {
781
- if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
782
- lines.push(` ${theme.fg("dim", "no rate-limit windows reported")}`);
1190
+ /** The update time shown once a refresh lands, as the clock time it happened. */
1191
+ function updatedAtLabel(report: QuotaReport): string {
1192
+ const at = new Date(report.generatedAt);
1193
+ if (Number.isNaN(at.getTime())) return "updated";
1194
+ return `updated ${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
1195
+ }
1196
+
1197
+ /**
1198
+ * Mask what must never reach a TUI frame or a scrollback. Mirrors
1199
+ * `src/http.js:redact` (this extension is one dependency-free file), and adds the
1200
+ * ANSI strip: a CLI's stderr can carry its own colour codes.
1201
+ */
1202
+ function redactCause(value: string): string {
1203
+ return value
1204
+ .replace(/\u001b\[[0-9;]*m/g, "")
1205
+ .replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "<email>")
1206
+ .replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{4,}\b/g, "<jwt>")
1207
+ .replace(/\b(sk-[A-Za-z0-9_-]{6})[A-Za-z0-9_-]+/g, "$1<redacted>")
1208
+ .replace(/\b(ya29\.)[A-Za-z0-9_-]+/g, "$1<redacted>")
1209
+ .replace(/\b(1\/\/)[A-Za-z0-9_-]+/g, "$1<redacted>")
1210
+ .replace(/\b(rt\.[\w.]{4})[A-Za-z0-9_-]+/g, "$1<redacted>")
1211
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1<redacted>")
1212
+ .replace(/\b((?:__Host-console_session|auth)=)[^;\s]+/gi, "$1<redacted>");
1213
+ }
1214
+
1215
+ /**
1216
+ * One short, redacted line for a failed `stats --json` run: the CLI's own first
1217
+ * stderr line when it printed one, otherwise its exit status. The raw parse
1218
+ * error ("Unexpected end of JSON input") says nothing about why the read failed.
1219
+ */
1220
+ function cliFailureLine(result: { stdout?: string; stderr?: string; code?: number; killed?: boolean } | null | undefined): string {
1221
+ const first = String(result?.stderr ?? "")
1222
+ .split("\n")
1223
+ .map((line) => line.trim())
1224
+ .find((line) => line !== "");
1225
+ const code = Number(result?.code);
1226
+ const cause = first
1227
+ ? redactCause(first)
1228
+ : result?.killed
1229
+ ? "piquota stats was stopped (timeout)"
1230
+ : Number.isFinite(code) && code !== 0
1231
+ ? `piquota stats exited ${code}`
1232
+ : "piquota stats --json printed no history payload";
1233
+ return capCause(cause);
1234
+ }
1235
+
1236
+ /** A stderr line or a thrown message can quote a whole path list; the frame gets one line. */
1237
+ function capCause(cause: string): string {
1238
+ return cause.length > 160 ? `${cause.slice(0, 157)}…` : cause;
1239
+ }
1240
+
1241
+ /** Local YYYY-MM-DD, the history's day key. */
1242
+ function localDayKey(now = new Date()): string {
1243
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
1244
+ }
1245
+
1246
+ /** Local YYYY-MM, the history's month key. */
1247
+ function localMonthKey(now = new Date()): string {
1248
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
1249
+ }
1250
+
1251
+ /** Tokens in one compact history model bucket. */
1252
+ function historyModelTokens(bucket: { tokens?: number } | undefined): number {
1253
+ return Math.max(0, Math.round(Number(bucket?.tokens) || 0));
1254
+ }
1255
+
1256
+ /** Tokens in one totals.json model bucket (the rollup's short keys). */
1257
+ function totalsModelTokens(bucket: HistoryTotalsModel | undefined): number {
1258
+ const values = [bucket?.input, bucket?.output, bucket?.cacheRead, bucket?.cacheWrite];
1259
+ return values.reduce((sum, value) => sum + Math.max(0, Math.round(Number(value) || 0)), 0);
1260
+ }
1261
+
1262
+ /** Slot labels sort "" (the unnumbered base) first, then #2, #3 … #10 numerically. */
1263
+ function slotOrder(label: string): number {
1264
+ if (label === "") return -1;
1265
+ const numeric = Number.parseInt(label.replace(/^#/, ""), 10);
1266
+ return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
1267
+ }
1268
+
1269
+ /**
1270
+ * Greedy wrap of " · "-joined cells: the first line carries the label, the
1271
+ * continuation lines are indented under it, and no cell is dropped.
1272
+ */
1273
+ function wrapCells(cells: string[], first: string, rest: string, width: number): string[] {
1274
+ const lines: string[] = [];
1275
+ let current = first;
1276
+ let count = 0;
1277
+ for (const cell of cells) {
1278
+ const separator = count === 0 ? "" : " · ";
1279
+ if (count > 0 && visibleLength(current) + separator.length + visibleLength(cell) > width) {
1280
+ lines.push(current);
1281
+ current = rest + cell;
1282
+ count = 1;
1283
+ continue;
1284
+ }
1285
+ current += separator + cell;
1286
+ count += 1;
1287
+ }
1288
+ lines.push(count > 0 ? current : first.trimEnd());
1289
+ return lines;
1290
+ }
1291
+
1292
+ /**
1293
+ * Split `count` cells into the fewest rows that fit `width`, then even them out.
1294
+ *
1295
+ * Greedy filling is the row count; a second pass picks, among all splits into
1296
+ * that many rows, the one whose widest row is the narrowest. That is what turns
1297
+ * a 7+1 legend into 4+4 without ever adding a row or dropping a pair.
1298
+ *
1299
+ * @returns the number of cells on each row.
1300
+ */
1301
+ function balanceRows(lengths: number[], width: number, separator: number): number[] {
1302
+ const count = lengths.length;
1303
+ if (count === 0) return [];
1304
+ const prefix: number[] = [0];
1305
+ for (const length of lengths) prefix.push(prefix[prefix.length - 1] + length);
1306
+ const cost = (from: number, to: number) => prefix[to] - prefix[from] + separator * (to - from - 1);
1307
+
1308
+ let rows = 1;
1309
+ let used = lengths[0];
1310
+ for (let index = 1; index < count; index += 1) {
1311
+ if (used + separator + lengths[index] <= width) used += separator + lengths[index];
1312
+ else {
1313
+ rows += 1;
1314
+ used = lengths[index];
1315
+ }
1316
+ }
1317
+ if (rows <= 1) return [count];
1318
+
1319
+ // best[i][r]: the narrowest possible widest row for cells[i..] in exactly r rows.
1320
+ const best: number[][] = Array.from({ length: count + 1 }, () => Array(rows + 1).fill(Number.POSITIVE_INFINITY));
1321
+ best[count][0] = 0;
1322
+ for (let remaining = 1; remaining <= rows; remaining += 1) {
1323
+ for (let from = count - 1; from >= 0; from -= 1) {
1324
+ for (let take = 1; take <= count - from - (remaining - 1); take += 1) {
1325
+ const rest = best[from + take][remaining - 1];
1326
+ if (rest === Number.POSITIVE_INFINITY) continue;
1327
+ const widest = Math.max(cost(from, from + take), rest);
1328
+ if (widest < best[from][remaining]) best[from][remaining] = widest;
1329
+ }
1330
+ }
1331
+ }
1332
+
1333
+ const counts: number[] = [];
1334
+ let from = 0;
1335
+ for (let remaining = rows; remaining > 0; remaining -= 1) {
1336
+ let chosen = 1;
1337
+ for (let take = 1; take <= count - from - (remaining - 1); take += 1) {
1338
+ const rest = best[from + take][remaining - 1];
1339
+ if (rest === Number.POSITIVE_INFINITY) continue;
1340
+ if (Math.max(cost(from, from + take), rest) === best[from][remaining]) {
1341
+ chosen = take;
1342
+ break;
1343
+ }
1344
+ }
1345
+ counts.push(chosen);
1346
+ from += chosen;
1347
+ }
1348
+ return counts;
1349
+ }
1350
+
1351
+ /**
1352
+ * The focused `/usage` view: two fullscreen pages the keyboard owns.
1353
+ *
1354
+ * Now is the panel, one collapsible section per provider, with a header cursor,
1355
+ * scrolling and a key legend. History is the durable token history for one
1356
+ * month, the same sections folded the same way, with ←/→ walking the months
1357
+ * that have a file. It reads the report, the error and the loaded month through
1358
+ * callbacks, so a background refresh only has to call redraw(); the view holds
1359
+ * no timer and no disk state.
1360
+ */
1361
+ export class UsageOverlay {
1362
+ private readonly theme: Theme;
1363
+ private readonly options: UsageOverlayOptions;
1364
+ /** Fold state and the page, shared with the next view for this pi session. */
1365
+ private readonly session: OverlaySession;
1366
+ private cursor = 0;
1367
+ private scroll = 0;
1368
+ private disposed = false;
1369
+ /** Months the CLI says are browsable, newest last. */
1370
+ private historyMonths: string[] = [];
1371
+ /** One loaded month per month key, kept for the lifetime of this overlay. */
1372
+ private readonly historyCache = new Map<string, HistoryPayload>();
1373
+ /** Whether History is idle, loading a month, or showing a failed fetch. */
1374
+ private historyStatus: HistoryStatus = { kind: "idle" };
1375
+ /**
1376
+ * Id of the newest `stats --json` request this view issued. ←/→ fire
1377
+ * overlapping fetches; only the newest answer may move the page.
1378
+ */
1379
+ private historyWanted = 0;
1380
+ /** Width of the last frame; history rows are wrapped for it. */
1381
+ private measureWidth = 80;
1382
+ private cachedLayout: { sections: PanelSection[]; tail: string[] } | null | undefined;
1383
+ private cachedLayoutWidth: number | undefined;
1384
+ private cachedMetrics: { rows: OverlayRow[]; headerRows: number[]; sectionEnds: number[] } | undefined;
1385
+ private cachedMetricsWidth: number | undefined;
1386
+ private cachedWidth: number | undefined;
1387
+ private cachedRows: number | undefined;
1388
+ private cachedLines: string[] | undefined;
1389
+ /** Legend rows the last frame needed; the viewport height depends on it. */
1390
+ private legendRows = 1;
1391
+
1392
+ constructor(theme: Theme, options: UsageOverlayOptions, session: OverlaySession = newOverlaySession()) {
1393
+ this.theme = theme;
1394
+ this.options = options;
1395
+ this.session = session;
1396
+ }
1397
+
1398
+ /** The fold map shared with the next view, so a reopen keeps every fold. */
1399
+ private get expanded(): Map<string, boolean> {
1400
+ return this.session.expanded;
1401
+ }
1402
+
1403
+ private get view(): "now" | "history" {
1404
+ return this.session.view;
1405
+ }
1406
+
1407
+ private set view(value: "now" | "history") {
1408
+ this.session.view = value;
1409
+ }
1410
+
1411
+ /** The month shown in History; null until a payload names one. */
1412
+ private get selectedMonth(): string | null {
1413
+ return this.session.month;
1414
+ }
1415
+
1416
+ private set selectedMonth(value: string | null) {
1417
+ this.session.month = value;
1418
+ }
1419
+
1420
+ /**
1421
+ * Load what a reopened view needs: a page that was closed on History has no
1422
+ * payload in its fresh cache, and its month must not render as empty.
1423
+ */
1424
+ public begin(): void {
1425
+ if (this.disposed || this.view !== "history") return;
1426
+ if (this.selectedMonth !== null && this.historyCache.has(this.selectedMonth)) return;
1427
+ this.requestMonth(this.selectedMonth);
1428
+ }
1429
+
1430
+ /** Drop cached rows and frames; the next render rebuilds from live state. */
1431
+ public invalidate(): void {
1432
+ this.cachedLayout = undefined;
1433
+ this.cachedLayoutWidth = undefined;
1434
+ this.cachedMetrics = undefined;
1435
+ this.cachedMetricsWidth = undefined;
1436
+ this.cachedWidth = undefined;
1437
+ this.cachedRows = undefined;
1438
+ this.cachedLines = undefined;
1439
+ }
1440
+
1441
+ /** Re-render after the extension's background refresh landed. */
1442
+ public redraw(): void {
1443
+ if (this.disposed) return;
1444
+ this.invalidate();
1445
+ this.options.requestRender();
1446
+ }
1447
+
1448
+ /** Stop drawing once the overlay closed, however it closed. */
1449
+ public dispose(): void {
1450
+ this.disposed = true;
1451
+ this.invalidate();
1452
+ }
1453
+
1454
+ /**
1455
+ * Ask for one month (null: the newest with a file) and remember the request
1456
+ * id, so the answer can be matched against the last keypress rather than the
1457
+ * order the CLI happened to finish in.
1458
+ */
1459
+ private requestMonth(month: string | null): void {
1460
+ // The counter lives in the module, not on the view: a reopened overlay must
1461
+ // not reuse an id, or an answer to the closed view's request would pass for
1462
+ // an answer to its own.
1463
+ historyRequestSeq += 1;
1464
+ this.historyWanted = historyRequestSeq;
1465
+ this.historyStatus = { kind: "loading", month };
1466
+ this.options.loadHistory(month, this.historyWanted);
1467
+ }
1468
+
1469
+ /**
1470
+ * A month (or the first payload, with the month list) arrived from the CLI.
1471
+ * Every answer is cached — a later ← must not refetch what already arrived —
1472
+ * but only the answer to the newest request may move the page: ←/→ overlap,
1473
+ * and a late answer for a month the last keypress did not ask for used to snap
1474
+ * the page back and skip a month.
1475
+ */
1476
+ public setHistory(payload: HistoryPayload, request?: number): void {
1477
+ if (this.disposed) return;
1478
+ if (Array.isArray(payload.months)) {
1479
+ this.historyMonths = payload.months.filter((month) => /^\d{4}-\d{2}$/.test(month));
1480
+ }
1481
+ const month = typeof payload.month === "string" && /^\d{4}-\d{2}$/.test(payload.month) ? payload.month : null;
1482
+ if (month !== null) this.historyCache.set(month, payload);
1483
+ // The newest request answered, so nothing is loading any more. It moves the
1484
+ // page only if the page still asks for that month: a newer keypress may have
1485
+ // left it, and the late answer then only fills the cache.
1486
+ if (request === undefined || request === this.historyWanted) {
1487
+ if (month !== null && (this.selectedMonth === null || month === this.selectedMonth)) this.selectedMonth = month;
1488
+ this.historyStatus = { kind: "idle" };
1489
+ }
1490
+ this.invalidate();
1491
+ this.options.requestRender();
1492
+ }
1493
+
1494
+ /** The history fetch failed: a line in the view, never a thrown error. */
1495
+ public setHistoryError(message: string, request?: number): void {
1496
+ if (this.disposed) return;
1497
+ // A failure that answers an older request must not paint over the month the
1498
+ // user is on now: its own request (or its cached payload) owns the frame.
1499
+ if (request !== undefined && request !== this.historyWanted) return;
1500
+ // Keep the month the failed fetch was for: a retry must ask for that month,
1501
+ // and the frame must say "failed" rather than "loading".
1502
+ const month = this.historyStatus.kind === "loading" ? this.historyStatus.month : this.selectedMonth;
1503
+ this.historyStatus = { kind: "failed", month, message };
1504
+ this.invalidate();
1505
+ this.options.requestRender();
1506
+ }
1507
+
1508
+ /** Handle one raw key: switch view, fold, move, month, refresh, close. */
1509
+ public handleInput(data: string): void {
1510
+ if (this.disposed) return;
1511
+ if (data === "\u001b" || data === "q") {
1512
+ this.dispose();
1513
+ this.options.done();
1514
+ return;
1515
+ }
1516
+ if (data === "\t" || data === "1" || data === "2") {
1517
+ const target = data === "1" ? "now" : data === "2" ? "history" : this.view === "now" ? "history" : "now";
1518
+ this.switchView(target);
1519
+ this.invalidate();
1520
+ return;
1521
+ }
1522
+ if (data === "r") {
1523
+ // History's today row comes from the live report, so a refresh there
1524
+ // reloads the month and the report together; one key, one meaning.
1525
+ if (this.view === "history") this.reloadMonth();
1526
+ this.options.refresh();
1527
+ this.invalidate();
1528
+ return;
1529
+ }
1530
+ if (isKey(data, "up") || data === "k") this.moveCursor(-1);
1531
+ else if (isKey(data, "down") || data === "j") this.moveCursor(1);
1532
+ else if (data === "\r" || data === "\n" || data === " ") this.toggleSection();
1533
+ else if (isKey(data, "pageUp")) this.page(-1);
1534
+ else if (isKey(data, "pageDown")) this.page(1);
1535
+ else if (isKey(data, "home") || data === "g") this.jumpTo(0);
1536
+ else if (isKey(data, "end") || data === "G") this.jumpTo(-1);
1537
+ else if (this.view === "history" && (isKey(data, "left") || data === "[")) this.moveMonth(-1);
1538
+ else if (this.view === "history" && (isKey(data, "right") || data === "]")) this.moveMonth(1);
1539
+ else if (isKey(data, "left") || data === "h") this.setExpanded(false);
1540
+ else if (isKey(data, "right") || data === "l") this.setExpanded(true);
1541
+ else return;
1542
+ // The TUI renders the component again after every key, so the cached frame
1543
+ // and the cached row map must be dropped here, not only on report changes.
1544
+ this.invalidate();
1545
+ }
1546
+
1547
+ /**
1548
+ * Wheel notches scroll the body; every other mouse event stays with pi.
1549
+ *
1550
+ * pi's TUI routes fullscreen wheel events to the focused component but offers
1551
+ * no scrolling API for a custom screen: unhandled notches only move a
1552
+ * ScrollView, and this extension ships as one dependency-free file that
1553
+ * cannot import one. So the wheel is handled here, exactly as pi-context-view
1554
+ * does (one notch = wheelDelta lines, undefined for every other event).
1555
+ */
1556
+ public handleMouse(event: { type?: string; wheelDelta?: number }): { handled: true; render: boolean } | undefined {
1557
+ if (this.disposed || event?.type !== "wheel" || !event.wheelDelta) return undefined;
1558
+ const before = this.scroll;
1559
+ this.scroll = clampScroll(this.scroll + event.wheelDelta, this.maxScroll());
1560
+ if (this.scroll === before) return { handled: true, render: false };
1561
+ this.invalidate();
1562
+ this.syncCursor();
1563
+ return { handled: true, render: true };
1564
+ }
1565
+
1566
+ /** Render one fullscreen frame for the given width and terminal height. */
1567
+ public render(width: number): string[] {
1568
+ const rows = terminalRows(this.options.getRows());
1569
+ if (this.cachedLines && this.cachedWidth === width && this.cachedRows === rows) return this.cachedLines;
1570
+
1571
+ this.measureWidth = width;
1572
+ // The legend wraps, so its row count depends on the width: it is measured
1573
+ // before the viewport is cut, and the frame reserves exactly those rows.
1574
+ const legend = this.legendLines(width);
1575
+ this.legendRows = legend.length;
1576
+ const { rows: content } = this.metrics();
1577
+ const height = Math.max(1, rows - this.frameRows());
1578
+ this.scroll = clampScroll(this.scroll, Math.max(0, content.length - height));
1579
+
1580
+ const viewport: string[] = [];
1581
+ for (const row of content.slice(this.scroll, this.scroll + height)) {
1582
+ const text = row.header ? `${row.section === this.cursor ? "❯" : " "} ${row.text}` : row.text;
1583
+ viewport.push(clipToWidth(text, width));
1584
+ }
1585
+ while (viewport.length < height) viewport.push("");
1586
+
1587
+ const title = this.titleLine();
1588
+ const frame = [spreadLine(title, this.statusText(content.length, height), width), "", ...viewport, "", ...legend];
1589
+ while (frame.length < rows) frame.push("");
1590
+ this.cachedWidth = width;
1591
+ this.cachedRows = rows;
1592
+ this.cachedLines = frame.slice(0, rows);
1593
+ return this.cachedLines;
1594
+ }
1595
+
1596
+ /** Rows the frame spends around the scrolling content: title, two blanks, legend. */
1597
+ private frameRows(): number {
1598
+ return 3 + Math.max(1, this.legendRows);
1599
+ }
1600
+
1601
+ /** Visible content height, excluding the fixed frame rows. */
1602
+ private viewportHeight(): number {
1603
+ return Math.max(1, terminalRows(this.options.getRows()) - this.frameRows());
1604
+ }
1605
+
1606
+ private maxScroll(): number {
1607
+ return Math.max(0, this.metrics().rows.length - this.viewportHeight());
1608
+ }
1609
+
1610
+ private isExpanded(key: string): boolean {
1611
+ return this.expanded.get(key) ?? true;
1612
+ }
1613
+
1614
+ /** The report split into its foldable sections; null while the first load runs. */
1615
+ private layout(): { sections: PanelSection[]; tail: string[] } | null {
1616
+ if (this.cachedLayout !== undefined && this.cachedLayoutWidth === this.measureWidth) return this.cachedLayout;
1617
+ this.cachedLayoutWidth = this.measureWidth;
1618
+ this.cachedLayout = this.view === "history" ? this.historyLayout(this.measureWidth) : this.nowLayout();
1619
+ return this.cachedLayout;
1620
+ }
1621
+
1622
+ /** The Now page: the exactly same sections the pinned panel renders flat. */
1623
+ private nowLayout(): { sections: PanelSection[]; tail: string[] } | null {
1624
+ const report = this.options.getReport();
1625
+ return report ? renderPanelLayout(report, this.theme) : null;
1626
+ }
1627
+
1628
+ /** Flattened rows, plus where every section's header starts and ends. */
1629
+ private metrics(): { rows: OverlayRow[]; headerRows: number[]; sectionEnds: number[] } {
1630
+ if (this.cachedMetrics && this.cachedMetricsWidth === this.measureWidth) return this.cachedMetrics;
1631
+ this.cachedMetricsWidth = this.measureWidth;
1632
+ const rows: OverlayRow[] = [];
1633
+ const headerRows: number[] = [];
1634
+ const sectionEnds: number[] = [];
1635
+ const layout = this.layout();
1636
+ if (this.view === "history") {
1637
+ const failure = this.historyFailure();
1638
+ if (failure !== null) {
1639
+ // With a month on screen the error is a line above it, so it carries the
1640
+ // retry hint itself; the no-payload state says it on the line below.
1641
+ const hint = layout ? ` ${this.theme.fg("dim", "· press r to retry")}` : "";
1642
+ // A CLI's stderr line is longer than a pane: wrapped, so neither the
1643
+ // cause nor the hint is lost to the right edge.
1644
+ this.pushRow(rows, ` ${this.theme.fg("error", `! ${failure}`)}${hint}`, -1, false);
1645
+ }
783
1646
  } else {
784
- if (provider.note) lines.push(` ${theme.fg("warning", `~ ${provider.note}`)}`);
785
- for (const window of provider.windows) {
786
- const used = usedOf(window);
787
- const label = theme.fg("dim", window.label.padEnd(labelWidth));
788
- if (used === null) {
789
- lines.push(` ${label} ${theme.fg("dim", window.note ?? "no percentage reported")}`);
790
- continue;
1647
+ const error = this.options.getError();
1648
+ if (error) rows.push({ text: ` ${this.theme.fg("error", `! ${error}`)}`, section: -1, header: false });
1649
+ }
1650
+ if (!layout) {
1651
+ // Distinct states, distinct lines: a failed fetch used to say "loading
1652
+ // history …" under its own error, which reads as a spinner that never ends.
1653
+ const status = this.historyStatus;
1654
+ const label = this.view !== "history"
1655
+ ? "loading quota …"
1656
+ : status.kind === "loading"
1657
+ ? status.month === null
1658
+ ? "loading history …"
1659
+ : `loading ${status.month} …`
1660
+ : status.kind === "failed"
1661
+ ? "no history for this month; press r to retry"
1662
+ : "loading history …";
1663
+ rows.push({ text: ` ${this.theme.fg("dim", label)}`, section: -1, header: false });
1664
+ } else {
1665
+ layout.sections.forEach((section, index) => {
1666
+ const open = this.isExpanded(section.key);
1667
+ headerRows.push(rows.length);
1668
+ this.pushRow(rows, `${open ? "▾" : "▸"} ${section.header}`, index, true);
1669
+ if (open) for (const line of section.body) this.pushRow(rows, ` ${line}`, index, false);
1670
+ sectionEnds.push(rows.length - 1);
1671
+ });
1672
+ for (const line of layout.tail) this.pushRow(rows, ` ${line}`, -1, false);
1673
+ }
1674
+ this.cursor = Math.max(0, Math.min(this.cursor, Math.max(0, headerRows.length - 1)));
1675
+ this.cachedMetrics = { rows, headerRows, sectionEnds };
1676
+ return this.cachedMetrics;
1677
+ }
1678
+
1679
+ /**
1680
+ * Append one content row, wrapped to the measured width. The cursor marker and
1681
+ * the body indent are two columns the wrap budget must leave room for, and a
1682
+ * wrapped header's continuation rows are body rows of the same section, so the
1683
+ * header indexes the cursor walks do not shift.
1684
+ */
1685
+ private pushRow(rows: OverlayRow[], text: string, section: number, header: boolean): void {
1686
+ const width = Math.max(8, this.measureWidth - (header ? 2 : 0));
1687
+ const parts = wrapLine(text, width);
1688
+ rows.push({ text: parts[0], section, header });
1689
+ for (const part of parts.slice(1)) rows.push({ text: header ? ` ${part}` : part, section, header: false });
1690
+ }
1691
+
1692
+ /** Walk the headers; a section taller than the viewport scrolls first. */
1693
+ private moveCursor(delta: -1 | 1): void {
1694
+ const { headerRows, sectionEnds } = this.metrics();
1695
+ if (headerRows.length === 0) return;
1696
+ const height = this.viewportHeight();
1697
+ const section = this.layout()?.sections[this.cursor];
1698
+ const open = section !== undefined && this.isExpanded(section.key);
1699
+ const end = sectionEnds[this.cursor] ?? 0;
1700
+ if (delta > 0 && open && end > this.scroll + height - 1) {
1701
+ this.scroll = clampScroll(this.scroll + 1, this.maxScroll());
1702
+ return;
1703
+ }
1704
+ if (delta < 0 && open && headerRows[this.cursor] < this.scroll) {
1705
+ this.scroll = clampScroll(this.scroll - 1, this.maxScroll());
1706
+ return;
1707
+ }
1708
+ const next = this.cursor + delta;
1709
+ if (next < 0 || next >= headerRows.length) {
1710
+ this.scroll = clampScroll(this.scroll + delta, this.maxScroll());
1711
+ return;
1712
+ }
1713
+ this.cursor = next;
1714
+ this.reveal();
1715
+ }
1716
+
1717
+ /** Scroll the cursor's header into view; a tall section starts at its header. */
1718
+ private reveal(): void {
1719
+ const { headerRows, sectionEnds } = this.metrics();
1720
+ const height = this.viewportHeight();
1721
+ const top = headerRows[this.cursor] ?? 0;
1722
+ const bottom = sectionEnds[this.cursor] ?? top;
1723
+ if (top < this.scroll || bottom > this.scroll + height - 1) this.scroll = top;
1724
+ this.scroll = clampScroll(this.scroll, this.maxScroll());
1725
+ }
1726
+
1727
+ /**
1728
+ * Page the body. When nothing can scroll the keystroke does nothing: paging
1729
+ * used to re-snap the cursor to the last on-screen header, so PgUp on a short
1730
+ * page moved the selection *down*.
1731
+ */
1732
+ private page(delta: -1 | 1): void {
1733
+ const before = this.scroll;
1734
+ this.scroll = clampScroll(this.scroll + delta * this.viewportHeight(), this.maxScroll());
1735
+ this.invalidate();
1736
+ if (this.scroll !== before) this.syncCursor();
1737
+ }
1738
+
1739
+ private jumpTo(index: 0 | -1): void {
1740
+ const { headerRows } = this.metrics();
1741
+ if (headerRows.length === 0) {
1742
+ this.scroll = index === 0 ? 0 : this.maxScroll();
1743
+ return;
1744
+ }
1745
+ this.cursor = index === 0 ? 0 : headerRows.length - 1;
1746
+ this.scroll = index === 0 ? 0 : this.maxScroll();
1747
+ const top = headerRows[this.cursor] ?? 0;
1748
+ if (top < this.scroll) this.scroll = top;
1749
+ }
1750
+
1751
+ /**
1752
+ * Keep the cursor on the header nearest the one it was on that is still on
1753
+ * screen. The last visible header is the wrong pick: scrolling a little used
1754
+ * to throw the selection to the bottom of the page. When no header is on
1755
+ * screen the cursor stays put, so scrolling back returns to it.
1756
+ */
1757
+ private syncCursor(): void {
1758
+ const { headerRows } = this.metrics();
1759
+ if (headerRows.length === 0) return;
1760
+ const bottom = this.scroll + this.viewportHeight() - 1;
1761
+ let best: number | null = null;
1762
+ for (let index = 0; index < headerRows.length; index += 1) {
1763
+ const row = headerRows[index];
1764
+ if (row < this.scroll || row > bottom) continue;
1765
+ if (best === null || Math.abs(index - this.cursor) < Math.abs(best - this.cursor)) best = index;
1766
+ }
1767
+ if (best !== null) this.cursor = best;
1768
+ }
1769
+
1770
+ private toggleSection(): void {
1771
+ const section = this.layout()?.sections[this.cursor];
1772
+ if (!section) return;
1773
+ this.setExpanded(!this.isExpanded(section.key));
1774
+ }
1775
+
1776
+ private setExpanded(open: boolean): void {
1777
+ const section = this.layout()?.sections[this.cursor];
1778
+ if (!section || this.isExpanded(section.key) === open) return;
1779
+ this.expanded.set(section.key, open);
1780
+ this.invalidate();
1781
+ this.reveal();
1782
+ }
1783
+
1784
+ private switchView(view: "now" | "history"): void {
1785
+ if (this.view === view) return;
1786
+ this.view = view;
1787
+ this.cursor = 0;
1788
+ this.scroll = 0;
1789
+ if (view === "history" && this.historyStatus.kind === "idle" && this.historyCache.size === 0) {
1790
+ this.requestMonth(this.selectedMonth);
1791
+ }
1792
+ }
1793
+
1794
+ /** ←/→ (or [ and ]) walk only the months that actually have a file. */
1795
+ private moveMonth(delta: -1 | 1): void {
1796
+ const months = this.historyMonths;
1797
+ if (months.length === 0) return;
1798
+ const current = this.selectedMonth;
1799
+ let target: string | null;
1800
+ if (current === null) {
1801
+ target = delta > 0 ? months[0] : months[months.length - 1];
1802
+ } else if (delta < 0) {
1803
+ target = [...months].reverse().find((month) => month < current) ?? null;
1804
+ } else {
1805
+ target = months.find((month) => month > current) ?? null;
1806
+ }
1807
+ if (target === null) return;
1808
+ this.selectMonth(target);
1809
+ }
1810
+
1811
+ private selectMonth(month: string): void {
1812
+ this.selectedMonth = month;
1813
+ this.cursor = 0;
1814
+ this.scroll = 0;
1815
+ if (!this.historyCache.has(month)) this.requestMonth(month);
1816
+ }
1817
+
1818
+ private reloadMonth(): void {
1819
+ this.requestMonth(this.selectedMonth);
1820
+ }
1821
+
1822
+ /**
1823
+ * The failure line for the month on screen, or null. A fetch that failed for a
1824
+ * month the user has since left must not paint over the page they are on, and
1825
+ * its "press r to retry" would retry the wrong month.
1826
+ */
1827
+ private historyFailure(): string | null {
1828
+ const status = this.historyStatus;
1829
+ if (status.kind !== "failed") return null;
1830
+ if (this.selectedMonth === null || status.month === null || status.month === this.selectedMonth) return status.message;
1831
+ return null;
1832
+ }
1833
+
1834
+ /** The History page: one section per family, days, models, accounts, totals. */
1835
+ private historyLayout(width: number): { sections: PanelSection[]; tail: string[] } | null {
1836
+ const month = this.selectedMonth;
1837
+ if (month === null) return null;
1838
+ const payload = this.historyCache.get(month);
1839
+ if (!payload) return null;
1840
+
1841
+ /** @type {Map<string, HistoryFamily>} */
1842
+ const families = new Map<string, HistoryFamily>();
1843
+ const ensure = (family: string): HistoryFamily => {
1844
+ let entry = families.get(family);
1845
+ if (!entry) {
1846
+ entry = {
1847
+ tokens: 0,
1848
+ prompts: 0,
1849
+ days: new Map(),
1850
+ models: new Map(),
1851
+ accounts: new Map(),
1852
+ allTime: 0,
1853
+ allTimePrompts: 0,
1854
+ allTimeAccounts: new Map(),
1855
+ liveTokens: 0,
1856
+ livePrompts: 0,
1857
+ liveAccounts: new Map(),
1858
+ };
1859
+ families.set(family, entry);
1860
+ }
1861
+ return entry;
1862
+ };
1863
+
1864
+ for (const [date, dayFamilies] of Object.entries(payload.days ?? {})) {
1865
+ for (const [family, day] of Object.entries(dayFamilies ?? {})) {
1866
+ const entry = ensure(family);
1867
+ const tokens = Math.max(0, Math.round(Number(day?.tokens) || 0));
1868
+ entry.tokens += tokens;
1869
+ entry.prompts += Math.max(0, Math.round(Number(day?.prompts) || 0));
1870
+ entry.days.set(date, (entry.days.get(date) ?? 0) + tokens);
1871
+ for (const [slot, slotRow] of Object.entries(day?.slots ?? {})) {
1872
+ const label = slot === "" ? "" : `#${slot}`;
1873
+ const account = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1874
+ account.tokens += Math.max(0, Math.round(Number(slotRow?.tokens) || 0));
1875
+ account.prompts += Math.max(0, Math.round(Number(slotRow?.prompts) || 0));
1876
+ for (const [model, bucket] of Object.entries(slotRow?.models ?? {})) {
1877
+ const modelTokens = historyModelTokens(bucket);
1878
+ account.models.set(model, (account.models.get(model) ?? 0) + modelTokens);
1879
+ entry.models.set(model, (entry.models.get(model) ?? 0) + modelTokens);
1880
+ }
1881
+ entry.accounts.set(label, account);
791
1882
  }
792
- const band = bandFor(used);
793
- const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
794
- lines.push(
795
- ` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
796
- `${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
797
- `${theme.fg("dim", "used")}${reset}`,
1883
+ }
1884
+ }
1885
+
1886
+ // The current month's open day is not frozen yet, so the month file has no
1887
+ // row for it. Merge today from the live report's localStats, by day key and
1888
+ // never by adding to an existing day: that is what makes double counting
1889
+ // impossible. The guard is per family, because one family the writer froze
1890
+ // today must not hide every other family's live row.
1891
+ //
1892
+ // The *report's* day decides, not the clock: `todayTotalTokens` is the day the
1893
+ // report was generated on. A pi session that crossed midnight still holds
1894
+ // yesterday's totals, and the first frame paints ~/.cache/pi-quota/usage.json,
1895
+ // which can be days old; merging either into today would add a frozen
1896
+ // yesterday to today. A stale report is skipped, and the tail says so.
1897
+ const now = new Date();
1898
+ const today = localDayKey(now);
1899
+ const report = this.options.getReport();
1900
+ const generatedAt = report?.generatedAt ? new Date(report.generatedAt) : null;
1901
+ // An unparseable generatedAt is no evidence of today: it is skipped, silently.
1902
+ const reportDay = generatedAt && !Number.isNaN(generatedAt.getTime()) ? localDayKey(generatedAt) : null;
1903
+ const frozenToday = payload.days?.[today] ?? {};
1904
+ let mergedToday = false;
1905
+ let skippedReportDay: string | null = null;
1906
+ if (month === localMonthKey(now)) {
1907
+ const stats = report?.localStats ?? {};
1908
+ for (const [family, familyStats] of Object.entries(stats)) {
1909
+ if (!familyStats || familyStats.hasLocalStats === false || frozenToday[family]) continue;
1910
+ const tokens = Math.max(0, Math.round(Number(familyStats.todayTotalTokens) || 0));
1911
+ const prompts = Math.max(0, Math.round(Number(familyStats.todayPrompts) || 0));
1912
+ if (tokens === 0 && prompts === 0) continue;
1913
+ // A day the report does not belong to is not today's row, however fresh
1914
+ // it looks: it is skipped whole, and never merged under today's key.
1915
+ if (reportDay !== today) {
1916
+ if (reportDay !== null) skippedReportDay = reportDay;
1917
+ break;
1918
+ }
1919
+ mergedToday = true;
1920
+ const entry = ensure(family);
1921
+ entry.tokens += tokens;
1922
+ entry.prompts += prompts;
1923
+ // Kept apart from the frozen month: the all-time figures add this same
1924
+ // delta below, and a live day may only ever be counted once in each.
1925
+ entry.liveTokens += tokens;
1926
+ entry.livePrompts += prompts;
1927
+ entry.days.set(today, (entry.days.get(today) ?? 0) + tokens);
1928
+ for (const [model, modelTokens] of Object.entries(familyStats.todayTokensByModel ?? {})) {
1929
+ entry.models.set(model, (entry.models.get(model) ?? 0) + Math.max(0, Math.round(Number(modelTokens) || 0)));
1930
+ }
1931
+ for (const [rawLabel, account] of Object.entries(familyStats.byAccount ?? {})) {
1932
+ const label = rawLabel === "#1" ? "" : rawLabel;
1933
+ const target = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1934
+ const accountTokens = Math.max(0, Math.round(Number(account?.todayTokens) || 0));
1935
+ const accountPrompts = Math.max(0, Math.round(Number(account?.todayPrompts) || 0));
1936
+ target.tokens += accountTokens;
1937
+ target.prompts += accountPrompts;
1938
+ const liveAccount = entry.liveAccounts.get(label) ?? { tokens: 0, prompts: 0 };
1939
+ entry.liveAccounts.set(label, { tokens: liveAccount.tokens + accountTokens, prompts: liveAccount.prompts + accountPrompts });
1940
+ // The account's own models for today: without them the merged row
1941
+ // shows a month total and no model, which reads as "no usage".
1942
+ for (const [model, modelTokens] of Object.entries(account?.todayTokensByModel ?? {})) {
1943
+ target.models.set(model, (target.models.get(model) ?? 0) + Math.max(0, Math.round(Number(modelTokens) || 0)));
1944
+ }
1945
+ entry.accounts.set(label, target);
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ for (const [family, totals] of Object.entries(payload.totals?.families ?? {})) {
1951
+ const entry = ensure(family);
1952
+ entry.allTime = Object.values(totals?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
1953
+ entry.allTimePrompts = Math.max(0, Math.round(Number(totals?.totalPrompts) || 0));
1954
+ for (const [rawLabel, account] of Object.entries(totals?.accounts ?? {})) {
1955
+ const label = rawLabel === "#1" ? "" : rawLabel;
1956
+ const tokens = Object.values(account?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
1957
+ const prompts = Math.max(0, Math.round(Number(account?.totalPrompts) || 0));
1958
+ const seen = entry.allTimeAccounts.get(label) ?? { tokens: 0, prompts: 0 };
1959
+ entry.allTimeAccounts.set(label, { tokens: seen.tokens + tokens, prompts: seen.prompts + prompts });
1960
+ }
1961
+ }
1962
+
1963
+ // Today's live day is part of all time, not only of this month: it is the
1964
+ // same usage the month row and the day row above already show, so it is
1965
+ // added here exactly once — the single place the live delta is folded into
1966
+ // a rollup, and only under the merge guard above (the day frozen, or a
1967
+ // report from another day, never produces a delta at all).
1968
+ //
1969
+ // A family with no rollup in totals.json gets nothing: there is no all-time
1970
+ // figure to extend, and calling today's tokens "all-time" would hide every
1971
+ // frozen day before it. The page already carries the CLI's note for that.
1972
+ for (const [family, entry] of families) {
1973
+ if (!payload.totals?.families?.[family]) continue;
1974
+ if (entry.liveTokens === 0 && entry.livePrompts === 0) continue;
1975
+ entry.allTime += entry.liveTokens;
1976
+ entry.allTimePrompts += entry.livePrompts;
1977
+ for (const [label, delta] of entry.liveAccounts) {
1978
+ const seen = entry.allTimeAccounts.get(label) ?? { tokens: 0, prompts: 0 };
1979
+ entry.allTimeAccounts.set(label, { tokens: seen.tokens + delta.tokens, prompts: seen.prompts + delta.prompts });
1980
+ }
1981
+ }
1982
+
1983
+ const sectionOf = (family: string): number => {
1984
+ const providers = this.options.getReport()?.providers ?? [];
1985
+ const index = providers.findIndex((provider) => provider.family === family);
1986
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
1987
+ };
1988
+ const ordered = Array.from(families.entries()).sort(
1989
+ (a, b) => sectionOf(a[0]) - sectionOf(b[0]) || a[0].localeCompare(b[0]),
1990
+ );
1991
+
1992
+ // Section bodies are indented by four columns in metrics(); the wrap budget
1993
+ // must leave room for that or a long day list is clipped at the right edge.
1994
+ const bodyWidth = Math.max(24, width - 4);
1995
+ const sections: PanelSection[] = [];
1996
+ for (const [family, entry] of ordered) {
1997
+ // Every account the family has in the month *or* all-time: a month in which
1998
+ // one account carried the usage must still show the family's other keys,
1999
+ // or the page hides the very rows that answer "which key burned what".
2000
+ const accountLabels = Array.from(new Set([...entry.accounts.keys(), ...entry.allTimeAccounts.keys()])).sort(
2001
+ (a, b) => slotOrder(a) - slotOrder(b),
2002
+ );
2003
+ // Multi-account families use the same short name as the Now panel header.
2004
+ const display = accountLabels.length > 1 ? SHORT_NAME[family] ?? FAMILY_LABEL[family] ?? family : FAMILY_LABEL[family] ?? family;
2005
+ const name = paint(BRAND[family] ?? "#8B949E", display);
2006
+ // The month first, then the all-time rollup with today's live day folded
2007
+ // in: every figure here counts a live day once. Prompts are scope-labelled
2008
+ // in their own row below, because an unlabelled count reads as the wrong
2009
+ // one of the two.
2010
+ //
2011
+ // Every all-time figure is computed before the first line is built: the
2012
+ // note a lagging rollup earns belongs at the top of the section, where it
2013
+ // explains the header, the prompts row and the account rows at once.
2014
+ const meta = [`${formatTokens(entry.tokens)} in ${month}`];
2015
+ const familyAllTime = allTimeFigure(entry.tokens, entry.allTime);
2016
+ if (familyAllTime.value > 0) meta.push(`${formatTokens(familyAllTime.value)} all-time`);
2017
+ const promptsAllTime = allTimeFigure(entry.prompts, entry.allTimePrompts);
2018
+ const accountAllTime = new Map<string, AllTimeFigure>();
2019
+ for (const accountLabel of accountLabels) {
2020
+ accountAllTime.set(
2021
+ accountLabel,
2022
+ allTimeFigure(entry.accounts.get(accountLabel)?.tokens ?? 0, entry.allTimeAccounts.get(accountLabel)?.tokens ?? 0),
2023
+ );
2024
+ }
2025
+ const lagsMonth =
2026
+ familyAllTime.lags || promptsAllTime.lags || Array.from(accountAllTime.values()).some((figure) => figure.lags);
2027
+ const body: string[] = [];
2028
+ if (lagsMonth) body.push(` ${this.theme.fg("warning", `~ ${ALL_TIME_LAGS_NOTE}`)}`);
2029
+ const label = (text: string) => ` ${this.theme.fg("dim", text.padEnd(6))} `;
2030
+ const rest = ` ${" ".repeat(8)}`;
2031
+ const dayCells = Array.from(entry.days.entries())
2032
+ .sort((a, b) => a[0].localeCompare(b[0]))
2033
+ .map(([date, tokens]) => `${date.slice(5)} ${formatTokens(tokens)}`);
2034
+ body.push(...wrapCells(
2035
+ dayCells.length > 0 ? dayCells : [this.theme.fg("dim", "no frozen days in this month")],
2036
+ label("days"),
2037
+ rest,
2038
+ bodyWidth,
2039
+ ));
2040
+ // Both scopes, always: the live day is merged into each of them once, so
2041
+ // the month count and the all-time count may never disagree about it.
2042
+ const promptCells: string[] = [];
2043
+ if (entry.prompts > 0) promptCells.push(`${entry.prompts} in ${month}`);
2044
+ if (promptsAllTime.value > 0) {
2045
+ // A figure that lags the month is still a figure: it is printed, dimmed
2046
+ // whole, and the note above the rows says why it is small.
2047
+ promptCells.push(
2048
+ promptsAllTime.lags
2049
+ ? this.theme.fg("dim", `${promptsAllTime.value} all-time`)
2050
+ : `${promptsAllTime.value} ${this.theme.fg("dim", "all-time")}`,
798
2051
  );
799
2052
  }
2053
+ if (promptCells.length > 0) {
2054
+ body.push(...wrapCells(promptCells, label("prompts"), rest, bodyWidth));
2055
+ }
2056
+ if (entry.models.size > 0) {
2057
+ const top = Array.from(entry.models.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
2058
+ const shown = top.slice(0, 5).map(([model, tokens]) => `${shortModelName(model)} ${formatTokens(tokens)}`);
2059
+ if (top.length > shown.length) shown.push(this.theme.fg("dim", `+${top.length - shown.length} more`));
2060
+ body.push(...wrapCells(shown, label("models"), rest, bodyWidth));
2061
+ }
2062
+ if (accountLabels.length > 1) {
2063
+ const labelWidth = Math.max(...accountLabels.map((text) => text.length));
2064
+ for (const accountLabel of accountLabels) {
2065
+ const account = entry.accounts.get(accountLabel) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
2066
+ const parts = [`${this.theme.fg("dim", "month")} ${formatTokens(account.tokens)}`];
2067
+ const accountFigure = accountAllTime.get(accountLabel) ?? { value: 0, lags: false };
2068
+ if (accountFigure.value > 0) {
2069
+ parts.push(
2070
+ accountFigure.lags
2071
+ ? this.theme.fg("dim", `all-time ${formatTokens(accountFigure.value)}`)
2072
+ : `${this.theme.fg("dim", "all-time")} ${formatTokens(accountFigure.value)}`,
2073
+ );
2074
+ }
2075
+ const best = Array.from(account.models.entries()).sort((a, b) => b[1] - a[1])[0];
2076
+ if (best) parts.push(this.theme.fg("dim", shortModelName(best[0])));
2077
+ body.push(` ${this.theme.fg("dim", accountLabel.padEnd(labelWidth))} ${parts.join(this.theme.fg("dim", " · "))}`);
2078
+ }
2079
+ }
2080
+ sections.push({ key: `history:${family}`, header: `${name} ${this.theme.fg("dim", `· ${meta.join(" · ")}`)}`, body });
800
2081
  }
801
2082
 
802
- // Local token stats survive a failed quota probe: the credential may be
803
- // rejected while the tokens already burned are still a fact on disk.
804
- lines.push(...renderTokenRows(localStats, labelWidth, theme, partialScan));
2083
+ const tail: string[] = [];
2084
+ if (ordered.length === 0) tail.push(this.theme.fg("dim", `no token history for ${month}`));
2085
+ for (const note of payload.notes ?? []) tail.push(this.theme.fg("warning", `! ${note}`));
2086
+ if (mergedToday) {
2087
+ tail.push(this.theme.fg("warning", `~ ${today} comes from the live report; it is not frozen in history yet`));
2088
+ } else if (skippedReportDay !== null) {
2089
+ // The report on screen is older than today, so its open-day totals belong
2090
+ // to another day: say so, or a missing today row reads as a lost day.
2091
+ tail.push(this.theme.fg("dim", `the live report is from ${skippedReportDay}; ${today} is not merged`));
2092
+ }
2093
+ const lastFrozen = payload.totals?.lastFrozenDay || "none";
2094
+ tail.push(this.theme.fg("dim", `frozen through ${lastFrozen} · ${month} · read-only`));
2095
+ return { sections, tail };
805
2096
  }
806
- lines.push(theme.fg("dim", `used % · read-only · ${report.generatedAt}`));
807
- if (unconfigured.length > 0) {
808
- const names = unconfigured.map((p) => SHORT_NAME[p.family] ?? p.family).join(", ");
809
- lines.push(theme.fg("dim", `not configured in Pi: ${names}`));
2097
+
2098
+ private titleLine(): string {
2099
+ const title = this.theme.fg("accent", this.theme.bold("Quota"));
2100
+ if (this.view !== "history") return title;
2101
+ const month = this.selectedMonth === null ? "…" : this.selectedMonth;
2102
+ return `${title} ${this.theme.fg("dim", `· History · ${month}`)}`;
810
2103
  }
811
- for (const warning of report.warnings.slice(0, 3)) {
812
- lines.push(theme.fg("warning", `warn: ${warning}`));
2104
+
2105
+ /**
2106
+ * The header's right side: a visible refresh indicator while the CLI runs,
2107
+ * the time the update landed afterwards, the view's position, and the scroll
2108
+ * range when the content is taller than the viewport.
2109
+ */
2110
+ private statusText(total: number, height: number): string {
2111
+ const parts: string[] = [];
2112
+ // History's today row is merged from the live report, so the report's state
2113
+ // (refreshing, its update time, a failed refresh) heads both views.
2114
+ if (this.options.isRefreshing()) {
2115
+ parts.push(this.theme.fg("accent", "refreshing …"));
2116
+ } else {
2117
+ const report = this.options.getReport();
2118
+ if (report) parts.push(this.theme.fg("dim", updatedAtLabel(report)));
2119
+ if (this.options.getError()) parts.push(this.theme.fg("error", "refresh failed"));
2120
+ }
2121
+ if (this.view === "history") {
2122
+ const status = this.historyStatus;
2123
+ // Only the month on screen reports its own fetch: a fetch for a month the
2124
+ // user already left must not claim the header.
2125
+ const shown = this.selectedMonth;
2126
+ const waitsForShown = shown === null || status.month === null || status.month === shown || !this.historyCache.has(shown);
2127
+ if (status.kind === "loading" && waitsForShown) {
2128
+ parts.push(this.theme.fg("accent", status.month === null ? "loading …" : `loading ${status.month} …`));
2129
+ } else if (this.historyFailure() !== null) {
2130
+ parts.push(this.theme.fg("error", "history failed"));
2131
+ }
2132
+ }
2133
+ if (total > height) {
2134
+ const last = Math.min(total, this.scroll + height);
2135
+ parts.push(this.theme.fg("dim", `${this.scroll + 1}-${last}/${total}`));
2136
+ }
2137
+ return parts.join(this.theme.fg("dim", " · "));
2138
+ }
2139
+
2140
+ /** Hint pairs for the active view; History adds its month navigation. */
2141
+ private legendHints(): ReadonlyArray<readonly [string, string]> {
2142
+ return this.view === "history" ? HISTORY_HINTS : NOW_HINTS;
2143
+ }
2144
+
2145
+ /**
2146
+ * Wrap the legend pairs across as many rows as the width needs, balanced:
2147
+ * greedy filling leaves the last pair alone on a row of its own ("esc/q
2148
+ * close" under seven pairs), which reads as a lost key. The row count is the
2149
+ * minimum greedy needs, so widening the pane never adds a row.
2150
+ */
2151
+ private legendLines(width: number): string[] {
2152
+ const separator = this.theme.fg("dim", " · ");
2153
+ const pairs = this.legendHints().map(([keys, label]) => `${this.theme.fg("dim", keys)} ${label}`);
2154
+ if (pairs.length === 0) return [""];
2155
+ const rows = balanceRows(pairs.map((pair) => visibleLength(pair)), width, 3);
2156
+ const lines: string[] = [];
2157
+ let index = 0;
2158
+ for (const count of rows) {
2159
+ lines.push(pairs.slice(index, index + count).join(separator));
2160
+ index += count;
2161
+ }
2162
+ return lines;
813
2163
  }
814
- return lines;
815
2164
  }
816
2165
 
817
2166
  export default function quotaPanelExtension(pi: ExtensionAPI): void {
@@ -821,10 +2170,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
821
2170
  let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
822
2171
  let panelVisible = false;
823
2172
  /**
824
- * The panel bare `/usage` opens is a glance, not a pin: it goes away when the
825
- * next prompt starts or when `/usage` is run again. `/usage panel` pins it.
2173
+ * The open fullscreen overlay, while there is one. Bare `/usage` opens it and
2174
+ * a background refresh redraws it; closing it leaves nothing behind.
826
2175
  */
827
- let transientPanel = false;
2176
+ let overlayView: UsageOverlay | null = null;
2177
+ /**
2178
+ * What a reopened overlay keeps: every bare `/usage` builds a fresh view, so
2179
+ * the folds and the page live here, for the pi session and never on disk.
2180
+ */
2181
+ let overlaySession = newOverlaySession();
828
2182
  /**
829
2183
  * Nothing is pinned by default. A box or line above the editor is a persistent
830
2184
  * surface, and a quota command should not claim a row until asked.
@@ -869,28 +2223,33 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
869
2223
  railMode = inspection.state === "patched";
870
2224
  }
871
2225
 
872
- async function runCli(args: string[]): Promise<QuotaReport | null> {
2226
+ /**
2227
+ * Run the CLI. A report run owns `lastError`; the advisory `omarchy` publish
2228
+ * writes human text, not JSON, and must never surface as a quota failure.
2229
+ */
2230
+ async function runCli(args: string[], options: { reportErrors?: boolean } = {}): Promise<QuotaReport | null> {
2231
+ const reportErrors = options.reportErrors !== false;
873
2232
  const cli = resolveCliPath();
874
2233
  if (!cli) {
875
- lastError = "piquota not installed: run install.sh from the pi-quota repo";
2234
+ if (reportErrors) lastError = "piquota not installed: run install.sh from the pi-quota repo";
876
2235
  return null;
877
2236
  }
878
2237
  try {
879
2238
  const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
880
2239
  const result = await pi.exec(runner, [cli, ...args], { timeout: EXEC_TIMEOUT_MS });
881
2240
  if (!result.stdout || result.stdout.trim() === "") {
882
- lastError = result.stderr?.trim() || "piquota produced no output";
2241
+ if (reportErrors) lastError = result.stderr?.trim() || "piquota produced no output";
883
2242
  return null;
884
2243
  }
885
2244
  const parsed = JSON.parse(result.stdout) as QuotaReport;
886
2245
  if (!parsed || !Array.isArray(parsed.providers)) {
887
- lastError = "piquota output was not a quota report";
2246
+ if (reportErrors) lastError = "piquota output was not a quota report";
888
2247
  return null;
889
2248
  }
890
- lastError = null;
2249
+ if (reportErrors) lastError = null;
891
2250
  return parsed;
892
2251
  } catch (error) {
893
- lastError = (error as { message?: string })?.message ?? String(error);
2252
+ if (reportErrors) lastError = (error as { message?: string })?.message ?? String(error);
894
2253
  return null;
895
2254
  }
896
2255
  }
@@ -1050,11 +2409,14 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1050
2409
  if (disposed) return;
1051
2410
  if (next) {
1052
2411
  report = next;
1053
- paintUi(ctx);
1054
- void runCli(["omarchy"]);
2412
+ // A bare /usage overlay is not a pinned surface: only repaint the widget
2413
+ // and the footer when something is actually on screen.
2414
+ if (surfaceActive()) paintUi(ctx);
2415
+ void runCli(["omarchy"], { reportErrors: false });
1055
2416
  }
1056
2417
  } finally {
1057
2418
  refreshing = false;
2419
+ overlayView?.redraw();
1058
2420
  }
1059
2421
  }
1060
2422
 
@@ -1063,6 +2425,8 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1063
2425
  const ui = ctx as unknown as UiContext;
1064
2426
  if (!ui.hasUI) return;
1065
2427
  disposed = false;
2428
+ // A new session starts with every section open, on the Now page.
2429
+ overlaySession = newOverlaySession();
1066
2430
  // Resolve the native rail mode before the first paint.
1067
2431
  await syncRailPatch();
1068
2432
  if (disposed) return;
@@ -1072,17 +2436,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1072
2436
  paintUi(ui);
1073
2437
  });
1074
2438
 
1075
- function dismissTransientPanel(ui: UiContext): void {
1076
- panelVisible = false;
1077
- transientPanel = false;
1078
- if (!surfaceActive()) stopPolling();
1079
- paintUi(ui);
1080
- }
1081
-
1082
- pi.on("agent_start", (_event, ctx) => {
1083
- // SAFETY: Pi's agent_start context implements UiContext when hasUI is true.
1084
- const ui = ctx as unknown as UiContext;
1085
- if (transientPanel && panelVisible && ui?.hasUI && !disposed) dismissTransientPanel(ui);
2439
+ pi.on("agent_start", () => {
1086
2440
  if (agentEndTimer) {
1087
2441
  clearTimeout(agentEndTimer);
1088
2442
  agentEndTimer = null;
@@ -1124,6 +2478,103 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1124
2478
  }
1125
2479
  });
1126
2480
 
2481
+ /**
2482
+ * Run `piquota stats --json` for one month (null: the newest with a file) and
2483
+ * hand the payload to the overlay. Read-only, no lock: the CLI only reads.
2484
+ *
2485
+ * A failure is reported the way the report path reports one: the CLI's own
2486
+ * stderr line or its exit status, redacted and on one line. The JSON parser's
2487
+ * "Unexpected end of JSON input" says nothing about the cause.
2488
+ */
2489
+ async function loadHistory(month: string | null, request: number): Promise<void> {
2490
+ const cli = resolveCliPath();
2491
+ if (!cli) {
2492
+ overlayView?.setHistoryError("piquota not installed: run install.sh from the pi-quota repo", request);
2493
+ return;
2494
+ }
2495
+ try {
2496
+ const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
2497
+ const argv = [cli, "stats", "--json", ...(month ? ["--month", month] : [])];
2498
+ const result = await pi.exec(runner, argv, { timeout: EXEC_TIMEOUT_MS });
2499
+ const stdout = typeof result?.stdout === "string" ? result.stdout : "";
2500
+ let parsed: HistoryPayload | null = null;
2501
+ try {
2502
+ parsed = stdout.trim() === "" ? null : (JSON.parse(stdout) as HistoryPayload);
2503
+ } catch {
2504
+ // A truncated or non-JSON stdout: report the run, not the parser.
2505
+ parsed = null;
2506
+ }
2507
+ if (!parsed || !Array.isArray(parsed.months)) {
2508
+ overlayView?.setHistoryError(cliFailureLine(result), request);
2509
+ return;
2510
+ }
2511
+ overlayView?.setHistory(parsed, request);
2512
+ } catch (error) {
2513
+ const message = (error as { message?: string })?.message ?? String(error);
2514
+ overlayView?.setHistoryError(capCause(redactCause(message).split("\n")[0]), request);
2515
+ }
2516
+ }
2517
+
2518
+ /**
2519
+ * Bare /usage: a fullscreen overlay that owns the keyboard and closes on Esc,
2520
+ * so no panel is left above the editor. It opens with the cached report and
2521
+ * refreshes in the background; a failure becomes a line in the view.
2522
+ */
2523
+ async function showOverlay(ctx: UiContext): Promise<void> {
2524
+ // Paint on the same keystroke: the report already in memory, otherwise the
2525
+ // CLI's on-disk cache. The forced refresh runs behind the overlay and never
2526
+ // delays the first frame.
2527
+ if (!report) report = readCachedReport();
2528
+ const custom = ctx.ui.custom;
2529
+ if (typeof custom !== "function" || (ctx.mode !== undefined && ctx.mode !== "tui")) {
2530
+ // A host without custom components still gets the panel that /usage panel pins.
2531
+ panelVisible = true;
2532
+ lineVisible = false;
2533
+ boxVisible = false;
2534
+ explicitBox = false;
2535
+ if (!report) await refresh(ctx, false);
2536
+ ensurePolling(ctx);
2537
+ paintUi(ctx);
2538
+ ctx.ui.notify("This host has no fullscreen overlay; the quota panel is pinned above the editor", "info");
2539
+ return;
2540
+ }
2541
+ await custom(
2542
+ (tui, theme, _keybindings, done) => {
2543
+ const view = new UsageOverlay(theme, {
2544
+ done: () => done(undefined),
2545
+ requestRender: () => tui?.requestRender?.(),
2546
+ getRows: () => tui?.terminal?.rows ?? process.stdout.rows ?? 24,
2547
+ getReport: () => report,
2548
+ getError: () => lastError,
2549
+ isRefreshing: () => refreshing,
2550
+ refresh: () => {
2551
+ void refresh(ctx, true);
2552
+ },
2553
+ loadHistory: (month, request) => {
2554
+ void loadHistory(month, request);
2555
+ },
2556
+ }, overlaySession);
2557
+ overlayView = view;
2558
+ // A view reopened on History holds no payload: ask for its month.
2559
+ view.begin();
2560
+ return {
2561
+ render: (width: number) => view.render(width),
2562
+ invalidate: () => view.invalidate(),
2563
+ dispose: () => {
2564
+ if (overlayView === view) overlayView = null;
2565
+ view.dispose();
2566
+ },
2567
+ handleInput: (data: string) => {
2568
+ view.handleInput(data);
2569
+ tui?.requestRender?.();
2570
+ },
2571
+ handleMouse: (event: any) => view.handleMouse(event),
2572
+ };
2573
+ },
2574
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 } },
2575
+ );
2576
+ }
2577
+
1127
2578
  const handler = async (args: string, ctx: UiContext): Promise<void> => {
1128
2579
  if (!ctx.hasUI) return;
1129
2580
  const action = args.trim().toLowerCase();
@@ -1151,7 +2602,6 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1151
2602
  return;
1152
2603
  case "panel":
1153
2604
  panelVisible = true;
1154
- transientPanel = false;
1155
2605
  lineVisible = false;
1156
2606
  boxVisible = false;
1157
2607
  explicitBox = false;
@@ -1198,29 +2648,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1198
2648
  break;
1199
2649
  }
1200
2650
 
1201
- // Bare /usage again while its glance is open closes it.
1202
- if (panelVisible && transientPanel) {
1203
- dismissTransientPanel(ctx);
1204
- return;
1205
- }
1206
-
1207
- // Bare /usage: one on-demand refresh, show the panel, report the percentages.
1208
- panelVisible = true;
1209
- transientPanel = true;
1210
- lineVisible = false;
1211
- boxVisible = false;
1212
- await refresh(ctx, true);
1213
- ensurePolling(ctx);
1214
- paintUi(ctx);
1215
- if (!report) {
1216
- ctx.ui.notify(`Quota unavailable: ${lastError}`, "error");
1217
- return;
1218
- }
1219
- ctx.ui.notify(summarize(report) || "No active providers configured in Pi", "info");
2651
+ // Bare /usage (or an unknown argument): open the overlay with whatever the
2652
+ // extension already has, and refresh in the background behind it.
2653
+ const opening = showOverlay(ctx);
2654
+ void refresh(ctx, true);
2655
+ await opening;
1220
2656
  };
1221
2657
 
1222
2658
  pi.registerCommand("usage", {
1223
- description: "Show read-only provider quota on demand (used %, shortest window)",
2659
+ description: "Open the read-only quota overlay (used %, one foldable section per provider)",
1224
2660
  getArgumentCompletions: (prefix: string) => {
1225
2661
  const options = ["refresh", "line", "box", "panel", "hide", "status", "nostatus", "json"];
1226
2662
  const matches = options.filter((option) => option.startsWith(prefix));