@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.
- package/CHANGELOG.md +21 -0
- package/README.md +85 -14
- package/bin/piquota.js +13 -2
- package/extensions/quota-panel.ts +1464 -136
- package/omarchy/cad0p.pi-usage/UsageLogic.js +9 -4
- package/package.json +1 -1
- package/src/cli/args.js +13 -0
- package/src/cli/commands/stats.js +149 -1
- package/src/engine.js +81 -23
- package/src/http.js +34 -1
- package/src/local/history.js +53 -7
- package/src/local/pi-sessions.js +20 -4
- package/src/omarchy/record.js +18 -11
- package/src/providers/opencode-go.js +4 -3
- package/src/render/panel.js +28 -7
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
757
|
-
|
|
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
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
1160
|
+
return report as QuotaReport;
|
|
1161
|
+
} catch {
|
|
1162
|
+
return null;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
773
1165
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
//
|
|
803
|
-
//
|
|
804
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
const
|
|
809
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
|
825
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
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: "
|
|
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));
|