@cad0p/pi-usage 0.11.4 → 0.11.5

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,1237 @@ 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
+ allTimeAccounts: Map<string, number>;
1040
+ };
1041
+
1042
+ /**
1043
+ * What outlives one overlay instance. Every bare `/usage` builds a fresh view,
1044
+ * so the folds and the page it was closed on live in the extension — for the pi
1045
+ * session only, and never on disk.
1046
+ */
1047
+ type OverlaySession = {
1048
+ expanded: Map<string, boolean>;
1049
+ view: "now" | "history";
1050
+ month: string | null;
1051
+ };
1052
+
1053
+ function newOverlaySession(): OverlaySession {
1054
+ return { expanded: new Map(), view: "now", month: null };
1055
+ }
1056
+
1057
+ /**
1058
+ * The History page's fetch state. "Not asked yet", "in flight" and "the last
1059
+ * fetch failed" are three different frames: the failed one must not also claim
1060
+ * to be loading.
1061
+ */
1062
+ type HistoryStatus =
1063
+ | { kind: "idle" }
1064
+ | { kind: "loading"; month: string | null }
1065
+ | { kind: "failed"; month: string | null; message: string };
1066
+
1067
+ /**
1068
+ * One id per `stats --json` request, for the whole pi process: a reopened overlay
1069
+ * issues the next id, so an answer to a closed view's request can never be taken
1070
+ * for an answer to its own.
1071
+ */
1072
+ let historyRequestSeq = 0;
1073
+
1074
+ type UsageOverlayOptions = {
1075
+ /** Close the overlay through the component's completion callback. */
1076
+ done: () => void;
1077
+ /** Ask the TUI for a frame after state changed. */
1078
+ requestRender: () => void;
1079
+ /** Current terminal height. */
1080
+ getRows: () => number;
1081
+ /** The newest report the extension holds, or null before the first one. */
1082
+ getReport: () => QuotaReport | null;
1083
+ /** The last refresh failure, shown as a line instead of thrown. */
1084
+ getError: () => string | null;
1085
+ isRefreshing: () => boolean;
1086
+ /** Run the same forced refresh `/usage` always ran, in the background. */
1087
+ refresh: () => void;
1088
+ /**
1089
+ * Ask the extension to run `piquota stats --json` for one month (null: newest).
1090
+ * The request id comes back with the answer, so a slow fetch cannot apply a
1091
+ * month the last keypress did not ask for.
1092
+ */
1093
+ loadHistory: (month: string | null, request: number) => void;
1094
+ };
1095
+
1096
+ /** Display names for a family the current report does not mention. */
1097
+ const FAMILY_LABEL: Record<string, string> = {
1098
+ claude: "Claude (Pi)",
1099
+ codex: "Codex (Pi)",
1100
+ antigravity: "Antigravity (Pi)",
1101
+ "opencode-go": "OpenCode Go (Pi)",
1102
+ "github-actions": "GitHub Actions",
1103
+ };
1104
+
1105
+ /**
1106
+ * The key legend. Every key the current view answers appears here; a pair a
1107
+ * view does not answer (month navigation outside History) is not listed. The
1108
+ * legend wraps instead of dropping pairs, so a 60-column terminal still shows
1109
+ * every key; the close key stays last.
1110
+ */
1111
+ const NOW_HINTS: ReadonlyArray<readonly [string, string]> = [
1112
+ ["↑↓/jk", "move"],
1113
+ ["enter/space/←→/hl", "fold"],
1114
+ ["pgup/pgdn", "page"],
1115
+ ["g/G home/end", "top/end"],
1116
+ ["wheel", "scroll"],
1117
+ ["r", "refresh"],
1118
+ ["tab/1/2", "history"],
1119
+ ["esc/q", "close"],
1120
+ ];
1121
+
1122
+ const HISTORY_HINTS: ReadonlyArray<readonly [string, string]> = [
1123
+ ["↑↓/jk", "move"],
1124
+ ["enter/space/h/l", "fold"],
1125
+ ["←/[", "prev month"],
1126
+ ["]/→", "next month"],
1127
+ ["pgup/pgdn", "page"],
1128
+ ["g/G home/end", "top/end"],
1129
+ ["wheel", "scroll"],
1130
+ ["r", "refresh"],
1131
+ ["tab/1/2", "now"],
1132
+ ["esc/q", "close"],
1133
+ ];
1134
+
1135
+ /** The report cache the CLI writes after every successful run. */
1136
+ function reportCachePath(): string {
1137
+ const cacheHome = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
1138
+ return join(cacheHome, "pi-quota", "usage.json");
1139
+ }
1140
+
1141
+ /**
1142
+ * The last report the CLI cached, read synchronously so the overlay can paint
1143
+ * on the same keystroke as /usage. Read-only, age is irrelevant for a first
1144
+ * frame, and a missing or malformed cache is simply "no cached report".
1145
+ */
1146
+ function readCachedReport(): QuotaReport | null {
1147
+ try {
1148
+ const parsed = JSON.parse(readFileSync(reportCachePath(), "utf-8"));
1149
+ const report = parsed?.report;
1150
+ if (!report || !Array.isArray(report.providers)) return null;
1151
+ // The cache stores absolute reset instants; the surfaces paint countdowns.
1152
+ const now = Date.now();
1153
+ for (const provider of report.providers) {
1154
+ for (const window of provider.windows ?? []) {
1155
+ if (!window.resetsAt) continue;
1156
+ const resetsAtMs = Date.parse(window.resetsAt);
1157
+ if (!Number.isNaN(resetsAtMs)) window.resetsInSec = Math.max(0, Math.round((resetsAtMs - now) / 1000));
1158
+ }
771
1159
  }
772
- lines.push(meta.length > 0 ? `${name} ${theme.fg("dim", `· ${meta.join(" · ")}`)}` : name);
1160
+ return report as QuotaReport;
1161
+ } catch {
1162
+ return null;
1163
+ }
1164
+ }
773
1165
 
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")}`);
1166
+ /** The update time shown once a refresh lands, as the clock time it happened. */
1167
+ function updatedAtLabel(report: QuotaReport): string {
1168
+ const at = new Date(report.generatedAt);
1169
+ if (Number.isNaN(at.getTime())) return "updated";
1170
+ return `updated ${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
1171
+ }
1172
+
1173
+ /**
1174
+ * Mask what must never reach a TUI frame or a scrollback. Mirrors
1175
+ * `src/http.js:redact` (this extension is one dependency-free file), and adds the
1176
+ * ANSI strip: a CLI's stderr can carry its own colour codes.
1177
+ */
1178
+ function redactCause(value: string): string {
1179
+ return value
1180
+ .replace(/\u001b\[[0-9;]*m/g, "")
1181
+ .replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "<email>")
1182
+ .replace(/\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{4,}\b/g, "<jwt>")
1183
+ .replace(/\b(sk-[A-Za-z0-9_-]{6})[A-Za-z0-9_-]+/g, "$1<redacted>")
1184
+ .replace(/\b(ya29\.)[A-Za-z0-9_-]+/g, "$1<redacted>")
1185
+ .replace(/\b(1\/\/)[A-Za-z0-9_-]+/g, "$1<redacted>")
1186
+ .replace(/\b(rt\.[\w.]{4})[A-Za-z0-9_-]+/g, "$1<redacted>")
1187
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1<redacted>")
1188
+ .replace(/\b((?:__Host-console_session|auth)=)[^;\s]+/gi, "$1<redacted>");
1189
+ }
1190
+
1191
+ /**
1192
+ * One short, redacted line for a failed `stats --json` run: the CLI's own first
1193
+ * stderr line when it printed one, otherwise its exit status. The raw parse
1194
+ * error ("Unexpected end of JSON input") says nothing about why the read failed.
1195
+ */
1196
+ function cliFailureLine(result: { stdout?: string; stderr?: string; code?: number; killed?: boolean } | null | undefined): string {
1197
+ const first = String(result?.stderr ?? "")
1198
+ .split("\n")
1199
+ .map((line) => line.trim())
1200
+ .find((line) => line !== "");
1201
+ const code = Number(result?.code);
1202
+ const cause = first
1203
+ ? redactCause(first)
1204
+ : result?.killed
1205
+ ? "piquota stats was stopped (timeout)"
1206
+ : Number.isFinite(code) && code !== 0
1207
+ ? `piquota stats exited ${code}`
1208
+ : "piquota stats --json printed no history payload";
1209
+ return capCause(cause);
1210
+ }
1211
+
1212
+ /** A stderr line or a thrown message can quote a whole path list; the frame gets one line. */
1213
+ function capCause(cause: string): string {
1214
+ return cause.length > 160 ? `${cause.slice(0, 157)}…` : cause;
1215
+ }
1216
+
1217
+ /** Local YYYY-MM-DD, the history's day key. */
1218
+ function localDayKey(now = new Date()): string {
1219
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
1220
+ }
1221
+
1222
+ /** Local YYYY-MM, the history's month key. */
1223
+ function localMonthKey(now = new Date()): string {
1224
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
1225
+ }
1226
+
1227
+ /** Tokens in one compact history model bucket. */
1228
+ function historyModelTokens(bucket: { tokens?: number } | undefined): number {
1229
+ return Math.max(0, Math.round(Number(bucket?.tokens) || 0));
1230
+ }
1231
+
1232
+ /** Tokens in one totals.json model bucket (the rollup's short keys). */
1233
+ function totalsModelTokens(bucket: HistoryTotalsModel | undefined): number {
1234
+ const values = [bucket?.input, bucket?.output, bucket?.cacheRead, bucket?.cacheWrite];
1235
+ return values.reduce((sum, value) => sum + Math.max(0, Math.round(Number(value) || 0)), 0);
1236
+ }
1237
+
1238
+ /** Slot labels sort "" (the unnumbered base) first, then #2, #3 … #10 numerically. */
1239
+ function slotOrder(label: string): number {
1240
+ if (label === "") return -1;
1241
+ const numeric = Number.parseInt(label.replace(/^#/, ""), 10);
1242
+ return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
1243
+ }
1244
+
1245
+ /**
1246
+ * Greedy wrap of " · "-joined cells: the first line carries the label, the
1247
+ * continuation lines are indented under it, and no cell is dropped.
1248
+ */
1249
+ function wrapCells(cells: string[], first: string, rest: string, width: number): string[] {
1250
+ const lines: string[] = [];
1251
+ let current = first;
1252
+ let count = 0;
1253
+ for (const cell of cells) {
1254
+ const separator = count === 0 ? "" : " · ";
1255
+ if (count > 0 && visibleLength(current) + separator.length + visibleLength(cell) > width) {
1256
+ lines.push(current);
1257
+ current = rest + cell;
1258
+ count = 1;
1259
+ continue;
1260
+ }
1261
+ current += separator + cell;
1262
+ count += 1;
1263
+ }
1264
+ lines.push(count > 0 ? current : first.trimEnd());
1265
+ return lines;
1266
+ }
1267
+
1268
+ /**
1269
+ * Split `count` cells into the fewest rows that fit `width`, then even them out.
1270
+ *
1271
+ * Greedy filling is the row count; a second pass picks, among all splits into
1272
+ * that many rows, the one whose widest row is the narrowest. That is what turns
1273
+ * a 7+1 legend into 4+4 without ever adding a row or dropping a pair.
1274
+ *
1275
+ * @returns the number of cells on each row.
1276
+ */
1277
+ function balanceRows(lengths: number[], width: number, separator: number): number[] {
1278
+ const count = lengths.length;
1279
+ if (count === 0) return [];
1280
+ const prefix: number[] = [0];
1281
+ for (const length of lengths) prefix.push(prefix[prefix.length - 1] + length);
1282
+ const cost = (from: number, to: number) => prefix[to] - prefix[from] + separator * (to - from - 1);
1283
+
1284
+ let rows = 1;
1285
+ let used = lengths[0];
1286
+ for (let index = 1; index < count; index += 1) {
1287
+ if (used + separator + lengths[index] <= width) used += separator + lengths[index];
1288
+ else {
1289
+ rows += 1;
1290
+ used = lengths[index];
1291
+ }
1292
+ }
1293
+ if (rows <= 1) return [count];
1294
+
1295
+ // best[i][r]: the narrowest possible widest row for cells[i..] in exactly r rows.
1296
+ const best: number[][] = Array.from({ length: count + 1 }, () => Array(rows + 1).fill(Number.POSITIVE_INFINITY));
1297
+ best[count][0] = 0;
1298
+ for (let remaining = 1; remaining <= rows; remaining += 1) {
1299
+ for (let from = count - 1; from >= 0; from -= 1) {
1300
+ for (let take = 1; take <= count - from - (remaining - 1); take += 1) {
1301
+ const rest = best[from + take][remaining - 1];
1302
+ if (rest === Number.POSITIVE_INFINITY) continue;
1303
+ const widest = Math.max(cost(from, from + take), rest);
1304
+ if (widest < best[from][remaining]) best[from][remaining] = widest;
1305
+ }
1306
+ }
1307
+ }
1308
+
1309
+ const counts: number[] = [];
1310
+ let from = 0;
1311
+ for (let remaining = rows; remaining > 0; remaining -= 1) {
1312
+ let chosen = 1;
1313
+ for (let take = 1; take <= count - from - (remaining - 1); take += 1) {
1314
+ const rest = best[from + take][remaining - 1];
1315
+ if (rest === Number.POSITIVE_INFINITY) continue;
1316
+ if (Math.max(cost(from, from + take), rest) === best[from][remaining]) {
1317
+ chosen = take;
1318
+ break;
1319
+ }
1320
+ }
1321
+ counts.push(chosen);
1322
+ from += chosen;
1323
+ }
1324
+ return counts;
1325
+ }
1326
+
1327
+ /**
1328
+ * The focused `/usage` view: two fullscreen pages the keyboard owns.
1329
+ *
1330
+ * Now is the panel, one collapsible section per provider, with a header cursor,
1331
+ * scrolling and a key legend. History is the durable token history for one
1332
+ * month, the same sections folded the same way, with ←/→ walking the months
1333
+ * that have a file. It reads the report, the error and the loaded month through
1334
+ * callbacks, so a background refresh only has to call redraw(); the view holds
1335
+ * no timer and no disk state.
1336
+ */
1337
+ export class UsageOverlay {
1338
+ private readonly theme: Theme;
1339
+ private readonly options: UsageOverlayOptions;
1340
+ /** Fold state and the page, shared with the next view for this pi session. */
1341
+ private readonly session: OverlaySession;
1342
+ private cursor = 0;
1343
+ private scroll = 0;
1344
+ private disposed = false;
1345
+ /** Months the CLI says are browsable, newest last. */
1346
+ private historyMonths: string[] = [];
1347
+ /** One loaded month per month key, kept for the lifetime of this overlay. */
1348
+ private readonly historyCache = new Map<string, HistoryPayload>();
1349
+ /** Whether History is idle, loading a month, or showing a failed fetch. */
1350
+ private historyStatus: HistoryStatus = { kind: "idle" };
1351
+ /**
1352
+ * Id of the newest `stats --json` request this view issued. ←/→ fire
1353
+ * overlapping fetches; only the newest answer may move the page.
1354
+ */
1355
+ private historyWanted = 0;
1356
+ /** Width of the last frame; history rows are wrapped for it. */
1357
+ private measureWidth = 80;
1358
+ private cachedLayout: { sections: PanelSection[]; tail: string[] } | null | undefined;
1359
+ private cachedLayoutWidth: number | undefined;
1360
+ private cachedMetrics: { rows: OverlayRow[]; headerRows: number[]; sectionEnds: number[] } | undefined;
1361
+ private cachedMetricsWidth: number | undefined;
1362
+ private cachedWidth: number | undefined;
1363
+ private cachedRows: number | undefined;
1364
+ private cachedLines: string[] | undefined;
1365
+ /** Legend rows the last frame needed; the viewport height depends on it. */
1366
+ private legendRows = 1;
1367
+
1368
+ constructor(theme: Theme, options: UsageOverlayOptions, session: OverlaySession = newOverlaySession()) {
1369
+ this.theme = theme;
1370
+ this.options = options;
1371
+ this.session = session;
1372
+ }
1373
+
1374
+ /** The fold map shared with the next view, so a reopen keeps every fold. */
1375
+ private get expanded(): Map<string, boolean> {
1376
+ return this.session.expanded;
1377
+ }
1378
+
1379
+ private get view(): "now" | "history" {
1380
+ return this.session.view;
1381
+ }
1382
+
1383
+ private set view(value: "now" | "history") {
1384
+ this.session.view = value;
1385
+ }
1386
+
1387
+ /** The month shown in History; null until a payload names one. */
1388
+ private get selectedMonth(): string | null {
1389
+ return this.session.month;
1390
+ }
1391
+
1392
+ private set selectedMonth(value: string | null) {
1393
+ this.session.month = value;
1394
+ }
1395
+
1396
+ /**
1397
+ * Load what a reopened view needs: a page that was closed on History has no
1398
+ * payload in its fresh cache, and its month must not render as empty.
1399
+ */
1400
+ public begin(): void {
1401
+ if (this.disposed || this.view !== "history") return;
1402
+ if (this.selectedMonth !== null && this.historyCache.has(this.selectedMonth)) return;
1403
+ this.requestMonth(this.selectedMonth);
1404
+ }
1405
+
1406
+ /** Drop cached rows and frames; the next render rebuilds from live state. */
1407
+ public invalidate(): void {
1408
+ this.cachedLayout = undefined;
1409
+ this.cachedLayoutWidth = undefined;
1410
+ this.cachedMetrics = undefined;
1411
+ this.cachedMetricsWidth = undefined;
1412
+ this.cachedWidth = undefined;
1413
+ this.cachedRows = undefined;
1414
+ this.cachedLines = undefined;
1415
+ }
1416
+
1417
+ /** Re-render after the extension's background refresh landed. */
1418
+ public redraw(): void {
1419
+ if (this.disposed) return;
1420
+ this.invalidate();
1421
+ this.options.requestRender();
1422
+ }
1423
+
1424
+ /** Stop drawing once the overlay closed, however it closed. */
1425
+ public dispose(): void {
1426
+ this.disposed = true;
1427
+ this.invalidate();
1428
+ }
1429
+
1430
+ /**
1431
+ * Ask for one month (null: the newest with a file) and remember the request
1432
+ * id, so the answer can be matched against the last keypress rather than the
1433
+ * order the CLI happened to finish in.
1434
+ */
1435
+ private requestMonth(month: string | null): void {
1436
+ // The counter lives in the module, not on the view: a reopened overlay must
1437
+ // not reuse an id, or an answer to the closed view's request would pass for
1438
+ // an answer to its own.
1439
+ historyRequestSeq += 1;
1440
+ this.historyWanted = historyRequestSeq;
1441
+ this.historyStatus = { kind: "loading", month };
1442
+ this.options.loadHistory(month, this.historyWanted);
1443
+ }
1444
+
1445
+ /**
1446
+ * A month (or the first payload, with the month list) arrived from the CLI.
1447
+ * Every answer is cached — a later ← must not refetch what already arrived —
1448
+ * but only the answer to the newest request may move the page: ←/→ overlap,
1449
+ * and a late answer for a month the last keypress did not ask for used to snap
1450
+ * the page back and skip a month.
1451
+ */
1452
+ public setHistory(payload: HistoryPayload, request?: number): void {
1453
+ if (this.disposed) return;
1454
+ if (Array.isArray(payload.months)) {
1455
+ this.historyMonths = payload.months.filter((month) => /^\d{4}-\d{2}$/.test(month));
1456
+ }
1457
+ const month = typeof payload.month === "string" && /^\d{4}-\d{2}$/.test(payload.month) ? payload.month : null;
1458
+ if (month !== null) this.historyCache.set(month, payload);
1459
+ // The newest request answered, so nothing is loading any more. It moves the
1460
+ // page only if the page still asks for that month: a newer keypress may have
1461
+ // left it, and the late answer then only fills the cache.
1462
+ if (request === undefined || request === this.historyWanted) {
1463
+ if (month !== null && (this.selectedMonth === null || month === this.selectedMonth)) this.selectedMonth = month;
1464
+ this.historyStatus = { kind: "idle" };
1465
+ }
1466
+ this.invalidate();
1467
+ this.options.requestRender();
1468
+ }
1469
+
1470
+ /** The history fetch failed: a line in the view, never a thrown error. */
1471
+ public setHistoryError(message: string, request?: number): void {
1472
+ if (this.disposed) return;
1473
+ // A failure that answers an older request must not paint over the month the
1474
+ // user is on now: its own request (or its cached payload) owns the frame.
1475
+ if (request !== undefined && request !== this.historyWanted) return;
1476
+ // Keep the month the failed fetch was for: a retry must ask for that month,
1477
+ // and the frame must say "failed" rather than "loading".
1478
+ const month = this.historyStatus.kind === "loading" ? this.historyStatus.month : this.selectedMonth;
1479
+ this.historyStatus = { kind: "failed", month, message };
1480
+ this.invalidate();
1481
+ this.options.requestRender();
1482
+ }
1483
+
1484
+ /** Handle one raw key: switch view, fold, move, month, refresh, close. */
1485
+ public handleInput(data: string): void {
1486
+ if (this.disposed) return;
1487
+ if (data === "\u001b" || data === "q") {
1488
+ this.dispose();
1489
+ this.options.done();
1490
+ return;
1491
+ }
1492
+ if (data === "\t" || data === "1" || data === "2") {
1493
+ const target = data === "1" ? "now" : data === "2" ? "history" : this.view === "now" ? "history" : "now";
1494
+ this.switchView(target);
1495
+ this.invalidate();
1496
+ return;
1497
+ }
1498
+ if (data === "r") {
1499
+ // History's today row comes from the live report, so a refresh there
1500
+ // reloads the month and the report together; one key, one meaning.
1501
+ if (this.view === "history") this.reloadMonth();
1502
+ this.options.refresh();
1503
+ this.invalidate();
1504
+ return;
1505
+ }
1506
+ if (isKey(data, "up") || data === "k") this.moveCursor(-1);
1507
+ else if (isKey(data, "down") || data === "j") this.moveCursor(1);
1508
+ else if (data === "\r" || data === "\n" || data === " ") this.toggleSection();
1509
+ else if (isKey(data, "pageUp")) this.page(-1);
1510
+ else if (isKey(data, "pageDown")) this.page(1);
1511
+ else if (isKey(data, "home") || data === "g") this.jumpTo(0);
1512
+ else if (isKey(data, "end") || data === "G") this.jumpTo(-1);
1513
+ else if (this.view === "history" && (isKey(data, "left") || data === "[")) this.moveMonth(-1);
1514
+ else if (this.view === "history" && (isKey(data, "right") || data === "]")) this.moveMonth(1);
1515
+ else if (isKey(data, "left") || data === "h") this.setExpanded(false);
1516
+ else if (isKey(data, "right") || data === "l") this.setExpanded(true);
1517
+ else return;
1518
+ // The TUI renders the component again after every key, so the cached frame
1519
+ // and the cached row map must be dropped here, not only on report changes.
1520
+ this.invalidate();
1521
+ }
1522
+
1523
+ /**
1524
+ * Wheel notches scroll the body; every other mouse event stays with pi.
1525
+ *
1526
+ * pi's TUI routes fullscreen wheel events to the focused component but offers
1527
+ * no scrolling API for a custom screen: unhandled notches only move a
1528
+ * ScrollView, and this extension ships as one dependency-free file that
1529
+ * cannot import one. So the wheel is handled here, exactly as pi-context-view
1530
+ * does (one notch = wheelDelta lines, undefined for every other event).
1531
+ */
1532
+ public handleMouse(event: { type?: string; wheelDelta?: number }): { handled: true; render: boolean } | undefined {
1533
+ if (this.disposed || event?.type !== "wheel" || !event.wheelDelta) return undefined;
1534
+ const before = this.scroll;
1535
+ this.scroll = clampScroll(this.scroll + event.wheelDelta, this.maxScroll());
1536
+ if (this.scroll === before) return { handled: true, render: false };
1537
+ this.invalidate();
1538
+ this.syncCursor();
1539
+ return { handled: true, render: true };
1540
+ }
1541
+
1542
+ /** Render one fullscreen frame for the given width and terminal height. */
1543
+ public render(width: number): string[] {
1544
+ const rows = terminalRows(this.options.getRows());
1545
+ if (this.cachedLines && this.cachedWidth === width && this.cachedRows === rows) return this.cachedLines;
1546
+
1547
+ this.measureWidth = width;
1548
+ // The legend wraps, so its row count depends on the width: it is measured
1549
+ // before the viewport is cut, and the frame reserves exactly those rows.
1550
+ const legend = this.legendLines(width);
1551
+ this.legendRows = legend.length;
1552
+ const { rows: content } = this.metrics();
1553
+ const height = Math.max(1, rows - this.frameRows());
1554
+ this.scroll = clampScroll(this.scroll, Math.max(0, content.length - height));
1555
+
1556
+ const viewport: string[] = [];
1557
+ for (const row of content.slice(this.scroll, this.scroll + height)) {
1558
+ const text = row.header ? `${row.section === this.cursor ? "❯" : " "} ${row.text}` : row.text;
1559
+ viewport.push(clipToWidth(text, width));
1560
+ }
1561
+ while (viewport.length < height) viewport.push("");
1562
+
1563
+ const title = this.titleLine();
1564
+ const frame = [spreadLine(title, this.statusText(content.length, height), width), "", ...viewport, "", ...legend];
1565
+ while (frame.length < rows) frame.push("");
1566
+ this.cachedWidth = width;
1567
+ this.cachedRows = rows;
1568
+ this.cachedLines = frame.slice(0, rows);
1569
+ return this.cachedLines;
1570
+ }
1571
+
1572
+ /** Rows the frame spends around the scrolling content: title, two blanks, legend. */
1573
+ private frameRows(): number {
1574
+ return 3 + Math.max(1, this.legendRows);
1575
+ }
1576
+
1577
+ /** Visible content height, excluding the fixed frame rows. */
1578
+ private viewportHeight(): number {
1579
+ return Math.max(1, terminalRows(this.options.getRows()) - this.frameRows());
1580
+ }
1581
+
1582
+ private maxScroll(): number {
1583
+ return Math.max(0, this.metrics().rows.length - this.viewportHeight());
1584
+ }
1585
+
1586
+ private isExpanded(key: string): boolean {
1587
+ return this.expanded.get(key) ?? true;
1588
+ }
1589
+
1590
+ /** The report split into its foldable sections; null while the first load runs. */
1591
+ private layout(): { sections: PanelSection[]; tail: string[] } | null {
1592
+ if (this.cachedLayout !== undefined && this.cachedLayoutWidth === this.measureWidth) return this.cachedLayout;
1593
+ this.cachedLayoutWidth = this.measureWidth;
1594
+ this.cachedLayout = this.view === "history" ? this.historyLayout(this.measureWidth) : this.nowLayout();
1595
+ return this.cachedLayout;
1596
+ }
1597
+
1598
+ /** The Now page: the exactly same sections the pinned panel renders flat. */
1599
+ private nowLayout(): { sections: PanelSection[]; tail: string[] } | null {
1600
+ const report = this.options.getReport();
1601
+ return report ? renderPanelLayout(report, this.theme) : null;
1602
+ }
1603
+
1604
+ /** Flattened rows, plus where every section's header starts and ends. */
1605
+ private metrics(): { rows: OverlayRow[]; headerRows: number[]; sectionEnds: number[] } {
1606
+ if (this.cachedMetrics && this.cachedMetricsWidth === this.measureWidth) return this.cachedMetrics;
1607
+ this.cachedMetricsWidth = this.measureWidth;
1608
+ const rows: OverlayRow[] = [];
1609
+ const headerRows: number[] = [];
1610
+ const sectionEnds: number[] = [];
1611
+ const layout = this.layout();
1612
+ if (this.view === "history") {
1613
+ const failure = this.historyFailure();
1614
+ if (failure !== null) {
1615
+ // With a month on screen the error is a line above it, so it carries the
1616
+ // retry hint itself; the no-payload state says it on the line below.
1617
+ const hint = layout ? ` ${this.theme.fg("dim", "· press r to retry")}` : "";
1618
+ // A CLI's stderr line is longer than a pane: wrapped, so neither the
1619
+ // cause nor the hint is lost to the right edge.
1620
+ this.pushRow(rows, ` ${this.theme.fg("error", `! ${failure}`)}${hint}`, -1, false);
1621
+ }
783
1622
  } 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;
1623
+ const error = this.options.getError();
1624
+ if (error) rows.push({ text: ` ${this.theme.fg("error", `! ${error}`)}`, section: -1, header: false });
1625
+ }
1626
+ if (!layout) {
1627
+ // Distinct states, distinct lines: a failed fetch used to say "loading
1628
+ // history …" under its own error, which reads as a spinner that never ends.
1629
+ const status = this.historyStatus;
1630
+ const label = this.view !== "history"
1631
+ ? "loading quota …"
1632
+ : status.kind === "loading"
1633
+ ? status.month === null
1634
+ ? "loading history …"
1635
+ : `loading ${status.month} …`
1636
+ : status.kind === "failed"
1637
+ ? "no history for this month; press r to retry"
1638
+ : "loading history …";
1639
+ rows.push({ text: ` ${this.theme.fg("dim", label)}`, section: -1, header: false });
1640
+ } else {
1641
+ layout.sections.forEach((section, index) => {
1642
+ const open = this.isExpanded(section.key);
1643
+ headerRows.push(rows.length);
1644
+ this.pushRow(rows, `${open ? "▾" : "▸"} ${section.header}`, index, true);
1645
+ if (open) for (const line of section.body) this.pushRow(rows, ` ${line}`, index, false);
1646
+ sectionEnds.push(rows.length - 1);
1647
+ });
1648
+ for (const line of layout.tail) this.pushRow(rows, ` ${line}`, -1, false);
1649
+ }
1650
+ this.cursor = Math.max(0, Math.min(this.cursor, Math.max(0, headerRows.length - 1)));
1651
+ this.cachedMetrics = { rows, headerRows, sectionEnds };
1652
+ return this.cachedMetrics;
1653
+ }
1654
+
1655
+ /**
1656
+ * Append one content row, wrapped to the measured width. The cursor marker and
1657
+ * the body indent are two columns the wrap budget must leave room for, and a
1658
+ * wrapped header's continuation rows are body rows of the same section, so the
1659
+ * header indexes the cursor walks do not shift.
1660
+ */
1661
+ private pushRow(rows: OverlayRow[], text: string, section: number, header: boolean): void {
1662
+ const width = Math.max(8, this.measureWidth - (header ? 2 : 0));
1663
+ const parts = wrapLine(text, width);
1664
+ rows.push({ text: parts[0], section, header });
1665
+ for (const part of parts.slice(1)) rows.push({ text: header ? ` ${part}` : part, section, header: false });
1666
+ }
1667
+
1668
+ /** Walk the headers; a section taller than the viewport scrolls first. */
1669
+ private moveCursor(delta: -1 | 1): void {
1670
+ const { headerRows, sectionEnds } = this.metrics();
1671
+ if (headerRows.length === 0) return;
1672
+ const height = this.viewportHeight();
1673
+ const section = this.layout()?.sections[this.cursor];
1674
+ const open = section !== undefined && this.isExpanded(section.key);
1675
+ const end = sectionEnds[this.cursor] ?? 0;
1676
+ if (delta > 0 && open && end > this.scroll + height - 1) {
1677
+ this.scroll = clampScroll(this.scroll + 1, this.maxScroll());
1678
+ return;
1679
+ }
1680
+ if (delta < 0 && open && headerRows[this.cursor] < this.scroll) {
1681
+ this.scroll = clampScroll(this.scroll - 1, this.maxScroll());
1682
+ return;
1683
+ }
1684
+ const next = this.cursor + delta;
1685
+ if (next < 0 || next >= headerRows.length) {
1686
+ this.scroll = clampScroll(this.scroll + delta, this.maxScroll());
1687
+ return;
1688
+ }
1689
+ this.cursor = next;
1690
+ this.reveal();
1691
+ }
1692
+
1693
+ /** Scroll the cursor's header into view; a tall section starts at its header. */
1694
+ private reveal(): void {
1695
+ const { headerRows, sectionEnds } = this.metrics();
1696
+ const height = this.viewportHeight();
1697
+ const top = headerRows[this.cursor] ?? 0;
1698
+ const bottom = sectionEnds[this.cursor] ?? top;
1699
+ if (top < this.scroll || bottom > this.scroll + height - 1) this.scroll = top;
1700
+ this.scroll = clampScroll(this.scroll, this.maxScroll());
1701
+ }
1702
+
1703
+ /**
1704
+ * Page the body. When nothing can scroll the keystroke does nothing: paging
1705
+ * used to re-snap the cursor to the last on-screen header, so PgUp on a short
1706
+ * page moved the selection *down*.
1707
+ */
1708
+ private page(delta: -1 | 1): void {
1709
+ const before = this.scroll;
1710
+ this.scroll = clampScroll(this.scroll + delta * this.viewportHeight(), this.maxScroll());
1711
+ this.invalidate();
1712
+ if (this.scroll !== before) this.syncCursor();
1713
+ }
1714
+
1715
+ private jumpTo(index: 0 | -1): void {
1716
+ const { headerRows } = this.metrics();
1717
+ if (headerRows.length === 0) {
1718
+ this.scroll = index === 0 ? 0 : this.maxScroll();
1719
+ return;
1720
+ }
1721
+ this.cursor = index === 0 ? 0 : headerRows.length - 1;
1722
+ this.scroll = index === 0 ? 0 : this.maxScroll();
1723
+ const top = headerRows[this.cursor] ?? 0;
1724
+ if (top < this.scroll) this.scroll = top;
1725
+ }
1726
+
1727
+ /**
1728
+ * Keep the cursor on the header nearest the one it was on that is still on
1729
+ * screen. The last visible header is the wrong pick: scrolling a little used
1730
+ * to throw the selection to the bottom of the page. When no header is on
1731
+ * screen the cursor stays put, so scrolling back returns to it.
1732
+ */
1733
+ private syncCursor(): void {
1734
+ const { headerRows } = this.metrics();
1735
+ if (headerRows.length === 0) return;
1736
+ const bottom = this.scroll + this.viewportHeight() - 1;
1737
+ let best: number | null = null;
1738
+ for (let index = 0; index < headerRows.length; index += 1) {
1739
+ const row = headerRows[index];
1740
+ if (row < this.scroll || row > bottom) continue;
1741
+ if (best === null || Math.abs(index - this.cursor) < Math.abs(best - this.cursor)) best = index;
1742
+ }
1743
+ if (best !== null) this.cursor = best;
1744
+ }
1745
+
1746
+ private toggleSection(): void {
1747
+ const section = this.layout()?.sections[this.cursor];
1748
+ if (!section) return;
1749
+ this.setExpanded(!this.isExpanded(section.key));
1750
+ }
1751
+
1752
+ private setExpanded(open: boolean): void {
1753
+ const section = this.layout()?.sections[this.cursor];
1754
+ if (!section || this.isExpanded(section.key) === open) return;
1755
+ this.expanded.set(section.key, open);
1756
+ this.invalidate();
1757
+ this.reveal();
1758
+ }
1759
+
1760
+ private switchView(view: "now" | "history"): void {
1761
+ if (this.view === view) return;
1762
+ this.view = view;
1763
+ this.cursor = 0;
1764
+ this.scroll = 0;
1765
+ if (view === "history" && this.historyStatus.kind === "idle" && this.historyCache.size === 0) {
1766
+ this.requestMonth(this.selectedMonth);
1767
+ }
1768
+ }
1769
+
1770
+ /** ←/→ (or [ and ]) walk only the months that actually have a file. */
1771
+ private moveMonth(delta: -1 | 1): void {
1772
+ const months = this.historyMonths;
1773
+ if (months.length === 0) return;
1774
+ const current = this.selectedMonth;
1775
+ let target: string | null;
1776
+ if (current === null) {
1777
+ target = delta > 0 ? months[0] : months[months.length - 1];
1778
+ } else if (delta < 0) {
1779
+ target = [...months].reverse().find((month) => month < current) ?? null;
1780
+ } else {
1781
+ target = months.find((month) => month > current) ?? null;
1782
+ }
1783
+ if (target === null) return;
1784
+ this.selectMonth(target);
1785
+ }
1786
+
1787
+ private selectMonth(month: string): void {
1788
+ this.selectedMonth = month;
1789
+ this.cursor = 0;
1790
+ this.scroll = 0;
1791
+ if (!this.historyCache.has(month)) this.requestMonth(month);
1792
+ }
1793
+
1794
+ private reloadMonth(): void {
1795
+ this.requestMonth(this.selectedMonth);
1796
+ }
1797
+
1798
+ /**
1799
+ * The failure line for the month on screen, or null. A fetch that failed for a
1800
+ * month the user has since left must not paint over the page they are on, and
1801
+ * its "press r to retry" would retry the wrong month.
1802
+ */
1803
+ private historyFailure(): string | null {
1804
+ const status = this.historyStatus;
1805
+ if (status.kind !== "failed") return null;
1806
+ if (this.selectedMonth === null || status.month === null || status.month === this.selectedMonth) return status.message;
1807
+ return null;
1808
+ }
1809
+
1810
+ /** The History page: one section per family, days, models, accounts, totals. */
1811
+ private historyLayout(width: number): { sections: PanelSection[]; tail: string[] } | null {
1812
+ const month = this.selectedMonth;
1813
+ if (month === null) return null;
1814
+ const payload = this.historyCache.get(month);
1815
+ if (!payload) return null;
1816
+
1817
+ /** @type {Map<string, HistoryFamily>} */
1818
+ const families = new Map<string, HistoryFamily>();
1819
+ const ensure = (family: string): HistoryFamily => {
1820
+ let entry = families.get(family);
1821
+ if (!entry) {
1822
+ entry = { tokens: 0, prompts: 0, days: new Map(), models: new Map(), accounts: new Map(), allTime: 0, allTimeAccounts: new Map() };
1823
+ families.set(family, entry);
1824
+ }
1825
+ return entry;
1826
+ };
1827
+
1828
+ for (const [date, dayFamilies] of Object.entries(payload.days ?? {})) {
1829
+ for (const [family, day] of Object.entries(dayFamilies ?? {})) {
1830
+ const entry = ensure(family);
1831
+ const tokens = Math.max(0, Math.round(Number(day?.tokens) || 0));
1832
+ entry.tokens += tokens;
1833
+ entry.prompts += Math.max(0, Math.round(Number(day?.prompts) || 0));
1834
+ entry.days.set(date, (entry.days.get(date) ?? 0) + tokens);
1835
+ for (const [slot, slotRow] of Object.entries(day?.slots ?? {})) {
1836
+ const label = slot === "" ? "" : `#${slot}`;
1837
+ const account = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1838
+ account.tokens += Math.max(0, Math.round(Number(slotRow?.tokens) || 0));
1839
+ account.prompts += Math.max(0, Math.round(Number(slotRow?.prompts) || 0));
1840
+ for (const [model, bucket] of Object.entries(slotRow?.models ?? {})) {
1841
+ const modelTokens = historyModelTokens(bucket);
1842
+ account.models.set(model, (account.models.get(model) ?? 0) + modelTokens);
1843
+ entry.models.set(model, (entry.models.get(model) ?? 0) + modelTokens);
1844
+ }
1845
+ entry.accounts.set(label, account);
791
1846
  }
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}`,
798
- );
799
1847
  }
800
1848
  }
801
1849
 
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));
1850
+ // The current month's open day is not frozen yet, so the month file has no
1851
+ // row for it. Merge today from the live report's localStats, by day key and
1852
+ // never by adding to an existing day: that is what makes double counting
1853
+ // impossible. The guard is per family, because one family the writer froze
1854
+ // today must not hide every other family's live row.
1855
+ //
1856
+ // The *report's* day decides, not the clock: `todayTotalTokens` is the day the
1857
+ // report was generated on. A pi session that crossed midnight still holds
1858
+ // yesterday's totals, and the first frame paints ~/.cache/pi-quota/usage.json,
1859
+ // which can be days old; merging either into today would add a frozen
1860
+ // yesterday to today. A stale report is skipped, and the tail says so.
1861
+ const now = new Date();
1862
+ const today = localDayKey(now);
1863
+ const report = this.options.getReport();
1864
+ const generatedAt = report?.generatedAt ? new Date(report.generatedAt) : null;
1865
+ // An unparseable generatedAt is no evidence of today: it is skipped, silently.
1866
+ const reportDay = generatedAt && !Number.isNaN(generatedAt.getTime()) ? localDayKey(generatedAt) : null;
1867
+ const frozenToday = payload.days?.[today] ?? {};
1868
+ let mergedToday = false;
1869
+ let skippedReportDay: string | null = null;
1870
+ if (month === localMonthKey(now)) {
1871
+ const stats = report?.localStats ?? {};
1872
+ for (const [family, familyStats] of Object.entries(stats)) {
1873
+ if (!familyStats || familyStats.hasLocalStats === false || frozenToday[family]) continue;
1874
+ const tokens = Math.max(0, Math.round(Number(familyStats.todayTotalTokens) || 0));
1875
+ const prompts = Math.max(0, Math.round(Number(familyStats.todayPrompts) || 0));
1876
+ if (tokens === 0 && prompts === 0) continue;
1877
+ // A day the report does not belong to is not today's row, however fresh
1878
+ // it looks: it is skipped whole, and never merged under today's key.
1879
+ if (reportDay !== today) {
1880
+ if (reportDay !== null) skippedReportDay = reportDay;
1881
+ break;
1882
+ }
1883
+ mergedToday = true;
1884
+ const entry = ensure(family);
1885
+ entry.tokens += tokens;
1886
+ entry.prompts += prompts;
1887
+ entry.days.set(today, (entry.days.get(today) ?? 0) + tokens);
1888
+ for (const [model, modelTokens] of Object.entries(familyStats.todayTokensByModel ?? {})) {
1889
+ entry.models.set(model, (entry.models.get(model) ?? 0) + Math.max(0, Math.round(Number(modelTokens) || 0)));
1890
+ }
1891
+ for (const [rawLabel, account] of Object.entries(familyStats.byAccount ?? {})) {
1892
+ const label = rawLabel === "#1" ? "" : rawLabel;
1893
+ const target = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1894
+ target.tokens += Math.max(0, Math.round(Number(account?.todayTokens) || 0));
1895
+ target.prompts += Math.max(0, Math.round(Number(account?.todayPrompts) || 0));
1896
+ // The account's own models for today: without them the merged row
1897
+ // shows a month total and no model, which reads as "no usage".
1898
+ for (const [model, modelTokens] of Object.entries(account?.todayTokensByModel ?? {})) {
1899
+ target.models.set(model, (target.models.get(model) ?? 0) + Math.max(0, Math.round(Number(modelTokens) || 0)));
1900
+ }
1901
+ entry.accounts.set(label, target);
1902
+ }
1903
+ }
1904
+ }
1905
+
1906
+ for (const [family, totals] of Object.entries(payload.totals?.families ?? {})) {
1907
+ const entry = ensure(family);
1908
+ entry.allTime = Object.values(totals?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
1909
+ for (const [rawLabel, account] of Object.entries(totals?.accounts ?? {})) {
1910
+ const label = rawLabel === "#1" ? "" : rawLabel;
1911
+ const tokens = Object.values(account?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
1912
+ entry.allTimeAccounts.set(label, (entry.allTimeAccounts.get(label) ?? 0) + tokens);
1913
+ }
1914
+ }
1915
+
1916
+ const sectionOf = (family: string): number => {
1917
+ const providers = this.options.getReport()?.providers ?? [];
1918
+ const index = providers.findIndex((provider) => provider.family === family);
1919
+ return index === -1 ? Number.MAX_SAFE_INTEGER : index;
1920
+ };
1921
+ const ordered = Array.from(families.entries()).sort(
1922
+ (a, b) => sectionOf(a[0]) - sectionOf(b[0]) || a[0].localeCompare(b[0]),
1923
+ );
1924
+
1925
+ // Section bodies are indented by four columns in metrics(); the wrap budget
1926
+ // must leave room for that or a long day list is clipped at the right edge.
1927
+ const bodyWidth = Math.max(24, width - 4);
1928
+ const sections: PanelSection[] = [];
1929
+ for (const [family, entry] of ordered) {
1930
+ // Every account the family has in the month *or* all-time: a month in which
1931
+ // one account carried the usage must still show the family's other keys,
1932
+ // or the page hides the very rows that answer "which key burned what".
1933
+ const accountLabels = Array.from(new Set([...entry.accounts.keys(), ...entry.allTimeAccounts.keys()])).sort(
1934
+ (a, b) => slotOrder(a) - slotOrder(b),
1935
+ );
1936
+ // Multi-account families use the same short name as the Now panel header.
1937
+ const display = accountLabels.length > 1 ? SHORT_NAME[family] ?? FAMILY_LABEL[family] ?? family : FAMILY_LABEL[family] ?? family;
1938
+ const name = paint(BRAND[family] ?? "#8B949E", display);
1939
+ const meta = [`${formatTokens(entry.tokens)} in ${month}`];
1940
+ if (entry.allTime > 0) meta.push(`${formatTokens(entry.allTime)} all-time`);
1941
+ if (entry.prompts > 0) meta.push(`${entry.prompts} prompt${entry.prompts === 1 ? "" : "s"}`);
1942
+ const body: string[] = [];
1943
+ const label = (text: string) => ` ${this.theme.fg("dim", text.padEnd(6))} `;
1944
+ const rest = ` ${" ".repeat(8)}`;
1945
+ const dayCells = Array.from(entry.days.entries())
1946
+ .sort((a, b) => a[0].localeCompare(b[0]))
1947
+ .map(([date, tokens]) => `${date.slice(5)} ${formatTokens(tokens)}`);
1948
+ body.push(...wrapCells(
1949
+ dayCells.length > 0 ? dayCells : [this.theme.fg("dim", "no frozen days in this month")],
1950
+ label("days"),
1951
+ rest,
1952
+ bodyWidth,
1953
+ ));
1954
+ if (entry.models.size > 0) {
1955
+ const top = Array.from(entry.models.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
1956
+ const shown = top.slice(0, 5).map(([model, tokens]) => `${shortModelName(model)} ${formatTokens(tokens)}`);
1957
+ if (top.length > shown.length) shown.push(this.theme.fg("dim", `+${top.length - shown.length} more`));
1958
+ body.push(...wrapCells(shown, label("models"), rest, bodyWidth));
1959
+ }
1960
+ if (accountLabels.length > 1) {
1961
+ const labelWidth = Math.max(...accountLabels.map((text) => text.length));
1962
+ for (const accountLabel of accountLabels) {
1963
+ const account = entry.accounts.get(accountLabel) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
1964
+ const parts = [`${this.theme.fg("dim", "month")} ${formatTokens(account.tokens)}`];
1965
+ const allTime = entry.allTimeAccounts.get(accountLabel) ?? 0;
1966
+ if (allTime > 0) parts.push(`${this.theme.fg("dim", "all-time")} ${formatTokens(allTime)}`);
1967
+ const best = Array.from(account.models.entries()).sort((a, b) => b[1] - a[1])[0];
1968
+ if (best) parts.push(this.theme.fg("dim", shortModelName(best[0])));
1969
+ body.push(` ${this.theme.fg("dim", accountLabel.padEnd(labelWidth))} ${parts.join(this.theme.fg("dim", " · "))}`);
1970
+ }
1971
+ }
1972
+ sections.push({ key: `history:${family}`, header: `${name} ${this.theme.fg("dim", `· ${meta.join(" · ")}`)}`, body });
1973
+ }
1974
+
1975
+ const tail: string[] = [];
1976
+ if (ordered.length === 0) tail.push(this.theme.fg("dim", `no token history for ${month}`));
1977
+ for (const note of payload.notes ?? []) tail.push(this.theme.fg("warning", `! ${note}`));
1978
+ if (mergedToday) {
1979
+ tail.push(this.theme.fg("warning", `~ ${today} comes from the live report; it is not frozen in history yet`));
1980
+ } else if (skippedReportDay !== null) {
1981
+ // The report on screen is older than today, so its open-day totals belong
1982
+ // to another day: say so, or a missing today row reads as a lost day.
1983
+ tail.push(this.theme.fg("dim", `the live report is from ${skippedReportDay}; ${today} is not merged`));
1984
+ }
1985
+ const lastFrozen = payload.totals?.lastFrozenDay || "none";
1986
+ tail.push(this.theme.fg("dim", `frozen through ${lastFrozen} · ${month} · read-only`));
1987
+ return { sections, tail };
805
1988
  }
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}`));
1989
+
1990
+ private titleLine(): string {
1991
+ const title = this.theme.fg("accent", this.theme.bold("Quota"));
1992
+ if (this.view !== "history") return title;
1993
+ const month = this.selectedMonth === null ? "…" : this.selectedMonth;
1994
+ return `${title} ${this.theme.fg("dim", `· History · ${month}`)}`;
810
1995
  }
811
- for (const warning of report.warnings.slice(0, 3)) {
812
- lines.push(theme.fg("warning", `warn: ${warning}`));
1996
+
1997
+ /**
1998
+ * The header's right side: a visible refresh indicator while the CLI runs,
1999
+ * the time the update landed afterwards, the view's position, and the scroll
2000
+ * range when the content is taller than the viewport.
2001
+ */
2002
+ private statusText(total: number, height: number): string {
2003
+ const parts: string[] = [];
2004
+ // History's today row is merged from the live report, so the report's state
2005
+ // (refreshing, its update time, a failed refresh) heads both views.
2006
+ if (this.options.isRefreshing()) {
2007
+ parts.push(this.theme.fg("accent", "refreshing …"));
2008
+ } else {
2009
+ const report = this.options.getReport();
2010
+ if (report) parts.push(this.theme.fg("dim", updatedAtLabel(report)));
2011
+ if (this.options.getError()) parts.push(this.theme.fg("error", "refresh failed"));
2012
+ }
2013
+ if (this.view === "history") {
2014
+ const status = this.historyStatus;
2015
+ // Only the month on screen reports its own fetch: a fetch for a month the
2016
+ // user already left must not claim the header.
2017
+ const shown = this.selectedMonth;
2018
+ const waitsForShown = shown === null || status.month === null || status.month === shown || !this.historyCache.has(shown);
2019
+ if (status.kind === "loading" && waitsForShown) {
2020
+ parts.push(this.theme.fg("accent", status.month === null ? "loading …" : `loading ${status.month} …`));
2021
+ } else if (this.historyFailure() !== null) {
2022
+ parts.push(this.theme.fg("error", "history failed"));
2023
+ }
2024
+ }
2025
+ if (total > height) {
2026
+ const last = Math.min(total, this.scroll + height);
2027
+ parts.push(this.theme.fg("dim", `${this.scroll + 1}-${last}/${total}`));
2028
+ }
2029
+ return parts.join(this.theme.fg("dim", " · "));
2030
+ }
2031
+
2032
+ /** Hint pairs for the active view; History adds its month navigation. */
2033
+ private legendHints(): ReadonlyArray<readonly [string, string]> {
2034
+ return this.view === "history" ? HISTORY_HINTS : NOW_HINTS;
2035
+ }
2036
+
2037
+ /**
2038
+ * Wrap the legend pairs across as many rows as the width needs, balanced:
2039
+ * greedy filling leaves the last pair alone on a row of its own ("esc/q
2040
+ * close" under seven pairs), which reads as a lost key. The row count is the
2041
+ * minimum greedy needs, so widening the pane never adds a row.
2042
+ */
2043
+ private legendLines(width: number): string[] {
2044
+ const separator = this.theme.fg("dim", " · ");
2045
+ const pairs = this.legendHints().map(([keys, label]) => `${this.theme.fg("dim", keys)} ${label}`);
2046
+ if (pairs.length === 0) return [""];
2047
+ const rows = balanceRows(pairs.map((pair) => visibleLength(pair)), width, 3);
2048
+ const lines: string[] = [];
2049
+ let index = 0;
2050
+ for (const count of rows) {
2051
+ lines.push(pairs.slice(index, index + count).join(separator));
2052
+ index += count;
2053
+ }
2054
+ return lines;
813
2055
  }
814
- return lines;
815
2056
  }
816
2057
 
817
2058
  export default function quotaPanelExtension(pi: ExtensionAPI): void {
@@ -821,10 +2062,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
821
2062
  let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
822
2063
  let panelVisible = false;
823
2064
  /**
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.
2065
+ * The open fullscreen overlay, while there is one. Bare `/usage` opens it and
2066
+ * a background refresh redraws it; closing it leaves nothing behind.
826
2067
  */
827
- let transientPanel = false;
2068
+ let overlayView: UsageOverlay | null = null;
2069
+ /**
2070
+ * What a reopened overlay keeps: every bare `/usage` builds a fresh view, so
2071
+ * the folds and the page live here, for the pi session and never on disk.
2072
+ */
2073
+ let overlaySession = newOverlaySession();
828
2074
  /**
829
2075
  * Nothing is pinned by default. A box or line above the editor is a persistent
830
2076
  * surface, and a quota command should not claim a row until asked.
@@ -869,28 +2115,33 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
869
2115
  railMode = inspection.state === "patched";
870
2116
  }
871
2117
 
872
- async function runCli(args: string[]): Promise<QuotaReport | null> {
2118
+ /**
2119
+ * Run the CLI. A report run owns `lastError`; the advisory `omarchy` publish
2120
+ * writes human text, not JSON, and must never surface as a quota failure.
2121
+ */
2122
+ async function runCli(args: string[], options: { reportErrors?: boolean } = {}): Promise<QuotaReport | null> {
2123
+ const reportErrors = options.reportErrors !== false;
873
2124
  const cli = resolveCliPath();
874
2125
  if (!cli) {
875
- lastError = "piquota not installed: run install.sh from the pi-quota repo";
2126
+ if (reportErrors) lastError = "piquota not installed: run install.sh from the pi-quota repo";
876
2127
  return null;
877
2128
  }
878
2129
  try {
879
2130
  const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
880
2131
  const result = await pi.exec(runner, [cli, ...args], { timeout: EXEC_TIMEOUT_MS });
881
2132
  if (!result.stdout || result.stdout.trim() === "") {
882
- lastError = result.stderr?.trim() || "piquota produced no output";
2133
+ if (reportErrors) lastError = result.stderr?.trim() || "piquota produced no output";
883
2134
  return null;
884
2135
  }
885
2136
  const parsed = JSON.parse(result.stdout) as QuotaReport;
886
2137
  if (!parsed || !Array.isArray(parsed.providers)) {
887
- lastError = "piquota output was not a quota report";
2138
+ if (reportErrors) lastError = "piquota output was not a quota report";
888
2139
  return null;
889
2140
  }
890
- lastError = null;
2141
+ if (reportErrors) lastError = null;
891
2142
  return parsed;
892
2143
  } catch (error) {
893
- lastError = (error as { message?: string })?.message ?? String(error);
2144
+ if (reportErrors) lastError = (error as { message?: string })?.message ?? String(error);
894
2145
  return null;
895
2146
  }
896
2147
  }
@@ -1050,11 +2301,14 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1050
2301
  if (disposed) return;
1051
2302
  if (next) {
1052
2303
  report = next;
1053
- paintUi(ctx);
1054
- void runCli(["omarchy"]);
2304
+ // A bare /usage overlay is not a pinned surface: only repaint the widget
2305
+ // and the footer when something is actually on screen.
2306
+ if (surfaceActive()) paintUi(ctx);
2307
+ void runCli(["omarchy"], { reportErrors: false });
1055
2308
  }
1056
2309
  } finally {
1057
2310
  refreshing = false;
2311
+ overlayView?.redraw();
1058
2312
  }
1059
2313
  }
1060
2314
 
@@ -1063,6 +2317,8 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1063
2317
  const ui = ctx as unknown as UiContext;
1064
2318
  if (!ui.hasUI) return;
1065
2319
  disposed = false;
2320
+ // A new session starts with every section open, on the Now page.
2321
+ overlaySession = newOverlaySession();
1066
2322
  // Resolve the native rail mode before the first paint.
1067
2323
  await syncRailPatch();
1068
2324
  if (disposed) return;
@@ -1072,17 +2328,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1072
2328
  paintUi(ui);
1073
2329
  });
1074
2330
 
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);
2331
+ pi.on("agent_start", () => {
1086
2332
  if (agentEndTimer) {
1087
2333
  clearTimeout(agentEndTimer);
1088
2334
  agentEndTimer = null;
@@ -1124,6 +2370,103 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1124
2370
  }
1125
2371
  });
1126
2372
 
2373
+ /**
2374
+ * Run `piquota stats --json` for one month (null: the newest with a file) and
2375
+ * hand the payload to the overlay. Read-only, no lock: the CLI only reads.
2376
+ *
2377
+ * A failure is reported the way the report path reports one: the CLI's own
2378
+ * stderr line or its exit status, redacted and on one line. The JSON parser's
2379
+ * "Unexpected end of JSON input" says nothing about the cause.
2380
+ */
2381
+ async function loadHistory(month: string | null, request: number): Promise<void> {
2382
+ const cli = resolveCliPath();
2383
+ if (!cli) {
2384
+ overlayView?.setHistoryError("piquota not installed: run install.sh from the pi-quota repo", request);
2385
+ return;
2386
+ }
2387
+ try {
2388
+ const runner = /node(\.exe)?$/i.test(process.execPath) ? process.execPath : "node";
2389
+ const argv = [cli, "stats", "--json", ...(month ? ["--month", month] : [])];
2390
+ const result = await pi.exec(runner, argv, { timeout: EXEC_TIMEOUT_MS });
2391
+ const stdout = typeof result?.stdout === "string" ? result.stdout : "";
2392
+ let parsed: HistoryPayload | null = null;
2393
+ try {
2394
+ parsed = stdout.trim() === "" ? null : (JSON.parse(stdout) as HistoryPayload);
2395
+ } catch {
2396
+ // A truncated or non-JSON stdout: report the run, not the parser.
2397
+ parsed = null;
2398
+ }
2399
+ if (!parsed || !Array.isArray(parsed.months)) {
2400
+ overlayView?.setHistoryError(cliFailureLine(result), request);
2401
+ return;
2402
+ }
2403
+ overlayView?.setHistory(parsed, request);
2404
+ } catch (error) {
2405
+ const message = (error as { message?: string })?.message ?? String(error);
2406
+ overlayView?.setHistoryError(capCause(redactCause(message).split("\n")[0]), request);
2407
+ }
2408
+ }
2409
+
2410
+ /**
2411
+ * Bare /usage: a fullscreen overlay that owns the keyboard and closes on Esc,
2412
+ * so no panel is left above the editor. It opens with the cached report and
2413
+ * refreshes in the background; a failure becomes a line in the view.
2414
+ */
2415
+ async function showOverlay(ctx: UiContext): Promise<void> {
2416
+ // Paint on the same keystroke: the report already in memory, otherwise the
2417
+ // CLI's on-disk cache. The forced refresh runs behind the overlay and never
2418
+ // delays the first frame.
2419
+ if (!report) report = readCachedReport();
2420
+ const custom = ctx.ui.custom;
2421
+ if (typeof custom !== "function" || (ctx.mode !== undefined && ctx.mode !== "tui")) {
2422
+ // A host without custom components still gets the panel that /usage panel pins.
2423
+ panelVisible = true;
2424
+ lineVisible = false;
2425
+ boxVisible = false;
2426
+ explicitBox = false;
2427
+ if (!report) await refresh(ctx, false);
2428
+ ensurePolling(ctx);
2429
+ paintUi(ctx);
2430
+ ctx.ui.notify("This host has no fullscreen overlay; the quota panel is pinned above the editor", "info");
2431
+ return;
2432
+ }
2433
+ await custom(
2434
+ (tui, theme, _keybindings, done) => {
2435
+ const view = new UsageOverlay(theme, {
2436
+ done: () => done(undefined),
2437
+ requestRender: () => tui?.requestRender?.(),
2438
+ getRows: () => tui?.terminal?.rows ?? process.stdout.rows ?? 24,
2439
+ getReport: () => report,
2440
+ getError: () => lastError,
2441
+ isRefreshing: () => refreshing,
2442
+ refresh: () => {
2443
+ void refresh(ctx, true);
2444
+ },
2445
+ loadHistory: (month, request) => {
2446
+ void loadHistory(month, request);
2447
+ },
2448
+ }, overlaySession);
2449
+ overlayView = view;
2450
+ // A view reopened on History holds no payload: ask for its month.
2451
+ view.begin();
2452
+ return {
2453
+ render: (width: number) => view.render(width),
2454
+ invalidate: () => view.invalidate(),
2455
+ dispose: () => {
2456
+ if (overlayView === view) overlayView = null;
2457
+ view.dispose();
2458
+ },
2459
+ handleInput: (data: string) => {
2460
+ view.handleInput(data);
2461
+ tui?.requestRender?.();
2462
+ },
2463
+ handleMouse: (event: any) => view.handleMouse(event),
2464
+ };
2465
+ },
2466
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", margin: 0 } },
2467
+ );
2468
+ }
2469
+
1127
2470
  const handler = async (args: string, ctx: UiContext): Promise<void> => {
1128
2471
  if (!ctx.hasUI) return;
1129
2472
  const action = args.trim().toLowerCase();
@@ -1151,7 +2494,6 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1151
2494
  return;
1152
2495
  case "panel":
1153
2496
  panelVisible = true;
1154
- transientPanel = false;
1155
2497
  lineVisible = false;
1156
2498
  boxVisible = false;
1157
2499
  explicitBox = false;
@@ -1198,29 +2540,15 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
1198
2540
  break;
1199
2541
  }
1200
2542
 
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");
2543
+ // Bare /usage (or an unknown argument): open the overlay with whatever the
2544
+ // extension already has, and refresh in the background behind it.
2545
+ const opening = showOverlay(ctx);
2546
+ void refresh(ctx, true);
2547
+ await opening;
1220
2548
  };
1221
2549
 
1222
2550
  pi.registerCommand("usage", {
1223
- description: "Show read-only provider quota on demand (used %, shortest window)",
2551
+ description: "Open the read-only quota overlay (used %, one foldable section per provider)",
1224
2552
  getArgumentCompletions: (prefix: string) => {
1225
2553
  const options = ["refresh", "line", "box", "panel", "hide", "status", "nostatus", "json"];
1226
2554
  const matches = options.filter((option) => option.startsWith(prefix));