@cad0p/pi-usage 0.11.5 → 0.11.6-20261107.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +102 -52
- package/bin/piquota.js +13 -3
- package/extensions/quota-panel.ts +567 -204
- package/omarchy/cad0p.pi-usage/UsageContent.qml +17 -1
- package/omarchy/cad0p.pi-usage/UsageLogic.js +13 -3
- package/omarchy/cad0p.pi-usage/assets/mistral.svg +18 -0
- package/package.json +3 -2
- package/src/auth/pi-auth.js +13 -0
- package/src/cache.js +128 -31
- package/src/cli/commands/omarchy.js +21 -3
- package/src/cli/explain.js +2 -0
- package/src/engine.js +105 -9
- package/src/local/history.js +29 -8
- package/src/local/pi-sessions.js +30 -3
- package/src/local/stats-worker.js +27 -0
- package/src/local/worker.js +157 -0
- package/src/model.js +39 -5
- package/src/omarchy/assets/mistral.svg +18 -0
- package/src/omarchy/publish.js +9 -8
- package/src/omarchy/record.js +24 -19
- package/src/overlay-keys.js +257 -0
- package/src/providers/antigravity-oauth.js +43 -3
- package/src/providers/antigravity.js +44 -5
- package/src/providers/claude.js +26 -0
- package/src/providers/codex.js +14 -0
- package/src/providers/mistral.js +251 -0
- package/src/providers/opencode-go.js +3 -3
- package/src/refresh.js +4 -1
- package/src/render/panel.js +23 -13
|
@@ -26,14 +26,26 @@
|
|
|
26
26
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
import { existsSync, readFileSync } from "node:fs";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import {
|
|
29
|
+
import { join } from "node:path";
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
import { extractChatGptAccountId, extractExpiresAtMs } from "../src/auth/jwt.js";
|
|
33
|
+
import { mergeReportIntoCache, writeCache } from "../src/cache.js";
|
|
34
|
+
import { normalizeClaudeSourceMode, collectFamily, enabledFamilies, FAMILIES } from "../src/engine.js";
|
|
35
|
+
import { historyJson } from "../src/cli/commands/stats.js";
|
|
36
|
+
import { resolveHistoryDir } from "../src/local/history.js";
|
|
37
|
+
import { startLocalStatsWorker } from "../src/local/worker.js";
|
|
38
|
+
import { publishRecords } from "../src/omarchy/publish.js";
|
|
39
|
+
import { buildRecords } from "../src/omarchy/record.js";
|
|
40
|
+
import { skippedFamilies } from "../src/cli/commands/omarchy.js";
|
|
41
|
+
import { isKeyRelease, matchesKey, matchesPrintable } from "../src/overlay-keys.js";
|
|
42
|
+
import { mergeLastGood } from "../src/sticky.js";
|
|
43
|
+
import { readRefreshState, resolveFamilyTtls, staleFamilies, writeRefreshState } from "../src/refresh.js";
|
|
44
|
+
|
|
45
|
+
// The in-process collector does not pass through the CLI's sticky layer, so this
|
|
46
|
+
// extension applies the same last-good merge as the CLI before painting or publishing.
|
|
47
|
+
// The classifier below mirrors src/providers/error-kind.js so the extension can
|
|
48
|
+
// render category-specific glyphs without depending on the repo.
|
|
37
49
|
|
|
38
50
|
const STATUS_KEY = "00-quota";
|
|
39
51
|
const WIDGET_KEY = "quota-line";
|
|
@@ -42,7 +54,12 @@ const RAIL_KEY = "quota";
|
|
|
42
54
|
const SIDEBAR_STATE_KEY = Symbol.for("gentle-pi.experimental-sidebar.state");
|
|
43
55
|
const REFRESH_MS = Number(process.env.PI_QUOTA_REFRESH_MS) || 300_000;
|
|
44
56
|
const AGENT_END_DEBOUNCE_MS = 1_500;
|
|
45
|
-
|
|
57
|
+
/** The report cache is keyed by the Claude source its numbers came from. */
|
|
58
|
+
const CLAUDE_SOURCE_CACHE_PREFIX = "claude-source:";
|
|
59
|
+
|
|
60
|
+
function cacheKeyFor(source: string): string {
|
|
61
|
+
return `${CLAUDE_SOURCE_CACHE_PREFIX}${normalizeClaudeSourceMode(source)}`;
|
|
62
|
+
}
|
|
46
63
|
|
|
47
64
|
type QuotaWindow = {
|
|
48
65
|
id: string;
|
|
@@ -60,6 +77,8 @@ type QuotaProvider = {
|
|
|
60
77
|
label: string;
|
|
61
78
|
primaryWindowId?: string | null;
|
|
62
79
|
account: string;
|
|
80
|
+
/** The auth.json slot: null for the base entry, "2" or "1-api" for a sibling. */
|
|
81
|
+
slot?: string | null;
|
|
63
82
|
plan: string | null;
|
|
64
83
|
windows: QuotaWindow[];
|
|
65
84
|
error: string | null;
|
|
@@ -124,9 +143,32 @@ type Theme = {
|
|
|
124
143
|
bg?: (key: string, text: string) => string;
|
|
125
144
|
};
|
|
126
145
|
|
|
146
|
+
/**
|
|
147
|
+
* One running local token scan. It is a handle, not a promise, because the scan
|
|
148
|
+
* can outlive the session that started it: a shutdown has to be able to end it
|
|
149
|
+
* and have its lock and cache left clean.
|
|
150
|
+
*/
|
|
151
|
+
type LocalStatsScan = {
|
|
152
|
+
result: Promise<{ byFamily: Record<string, LocalFamilyStats>; warnings: string[]; scan: { partial?: boolean } | null }>;
|
|
153
|
+
stop: () => Promise<void>;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
type ExtensionDependencies = {
|
|
157
|
+
collectFamily?: typeof collectFamily;
|
|
158
|
+
startLocalStats?: () => LocalStatsScan;
|
|
159
|
+
getProviderAuth?: (ctx: UiContext, provider: string) => Promise<{ auth?: { apiKey?: string }; accountId?: string; source?: string } | undefined>;
|
|
160
|
+
loadHistory?: (month: string | null) => Promise<HistoryPayload> | HistoryPayload;
|
|
161
|
+
publishOmarchy?: (report: QuotaReport, prune: boolean) => void;
|
|
162
|
+
railPatcherPath?: string;
|
|
163
|
+
};
|
|
164
|
+
|
|
127
165
|
/** Only the UI surface this extension uses. */
|
|
128
166
|
type UiContext = {
|
|
129
167
|
hasUI: boolean;
|
|
168
|
+
/** Pi resolves/renews its OAuth credentials under its own auth-store lock. */
|
|
169
|
+
modelRegistry?: {
|
|
170
|
+
getProviderAuth?: (provider: string) => Promise<{ auth?: { apiKey?: string }; source?: string } | undefined>;
|
|
171
|
+
};
|
|
130
172
|
/** Pi's run mode; `custom` components only exist in the TUI. */
|
|
131
173
|
mode?: string;
|
|
132
174
|
ui: {
|
|
@@ -147,6 +189,7 @@ const BRAND: Record<string, string> = {
|
|
|
147
189
|
codex: "#10A37F",
|
|
148
190
|
antigravity: "#4285F4",
|
|
149
191
|
"opencode-go": "#007AFF",
|
|
192
|
+
mistral: "#FA520F",
|
|
150
193
|
"github-actions": "#2088FF",
|
|
151
194
|
};
|
|
152
195
|
|
|
@@ -156,6 +199,7 @@ const SHORT_NAME: Record<string, string> = {
|
|
|
156
199
|
codex: "Codex",
|
|
157
200
|
antigravity: "Agy",
|
|
158
201
|
"opencode-go": "OP-Go",
|
|
202
|
+
mistral: "Mistral",
|
|
159
203
|
"github-actions": "GH",
|
|
160
204
|
};
|
|
161
205
|
|
|
@@ -193,23 +237,7 @@ function usedOf(window: QuotaWindow | null): number | null {
|
|
|
193
237
|
return null;
|
|
194
238
|
}
|
|
195
239
|
|
|
196
|
-
|
|
197
|
-
const override = process.env.PI_QUOTA_BIN;
|
|
198
|
-
if (override && existsSync(override)) return override;
|
|
199
|
-
try {
|
|
200
|
-
const sibling = fileURLToPath(new URL("../bin/piquota.js", import.meta.url));
|
|
201
|
-
if (existsSync(sibling)) return sibling;
|
|
202
|
-
} catch {}
|
|
203
|
-
for (const candidate of [
|
|
204
|
-
join(homedir(), ".local", "share", "pi-quota", "bin", "piquota.js"),
|
|
205
|
-
join(homedir(), ".local", "bin", "piquota"),
|
|
206
|
-
join(homedir(), ".pi", "agent", "npm", "node_modules", "@cad0p", "pi-usage", "bin", "piquota.js"),
|
|
207
|
-
join(homedir(), ".pi", "agent", "npm", "node_modules", "@j3pf", "piquota", "bin", "piquota.js"),
|
|
208
|
-
]) {
|
|
209
|
-
if (existsSync(candidate)) return candidate;
|
|
210
|
-
}
|
|
211
|
-
return null;
|
|
212
|
-
}
|
|
240
|
+
|
|
213
241
|
|
|
214
242
|
/**
|
|
215
243
|
* Rank by how soon a window constrains you: 5h/session first, then daily,
|
|
@@ -445,6 +473,9 @@ export function renderBox(report: QuotaReport, theme: Theme, totalWidth?: number
|
|
|
445
473
|
{ label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
|
|
446
474
|
{ label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
|
|
447
475
|
];
|
|
476
|
+
if (providerFor("mistral") && providerFor("mistral")?.notConfigured !== true) {
|
|
477
|
+
rows.push({ label: "Mistral:", color: "#FA520F", ...windowFor("mistral", (window) => window.id === "vibe") });
|
|
478
|
+
}
|
|
448
479
|
if (providerFor("github-actions")) {
|
|
449
480
|
rows.push({ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") });
|
|
450
481
|
}
|
|
@@ -494,6 +525,9 @@ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: numb
|
|
|
494
525
|
{ label: "Agy: ", color: "#4285F4", ...windowFor("antigravity", (window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window))) },
|
|
495
526
|
{ label: "Agy C/G:", color: "#7AA2F7", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
|
|
496
527
|
{ label: "OP-Go: ", color: "#007AFF", ...windowFor("opencode-go", isFiveHour) },
|
|
528
|
+
...(providerFor("mistral") && providerFor("mistral")?.notConfigured !== true
|
|
529
|
+
? [{ label: "Mistral:", color: "#FA520F", ...windowFor("mistral", (window) => window.id === "vibe") }]
|
|
530
|
+
: []),
|
|
497
531
|
...(providerFor("github-actions")
|
|
498
532
|
? [{ label: "GH Actions:", color: "#2088FF", ...windowFor("github-actions", (window) => window.id === "monthly") }]
|
|
499
533
|
: []),
|
|
@@ -759,20 +793,22 @@ function accountReason(error: string | null | undefined): string {
|
|
|
759
793
|
}
|
|
760
794
|
|
|
761
795
|
/**
|
|
762
|
-
* The slot label of an account: "" for the base auth.json entry
|
|
763
|
-
*
|
|
764
|
-
* report cached
|
|
796
|
+
* The slot label of an account: "#1" for the base auth.json entry, "#<slot>"
|
|
797
|
+
* for a sibling ("#2", "#1-api"). Never an identity such as Claude's
|
|
798
|
+
* "id c64efc0f…". A report cached without `slot` falls back to an account that
|
|
799
|
+
* already is a slot label; one cached while the base was unlabelled says "".
|
|
765
800
|
*/
|
|
766
|
-
function accountLabel(
|
|
767
|
-
|
|
768
|
-
if (
|
|
769
|
-
|
|
801
|
+
function accountLabel(provider: Pick<QuotaProvider, "account" | "slot">): string {
|
|
802
|
+
if (typeof provider.slot === "string" && provider.slot !== "") return `#${provider.slot}`;
|
|
803
|
+
if (provider.slot === null || provider.slot === "") return "#1";
|
|
804
|
+
const account = String(provider.account ?? "");
|
|
805
|
+
return /^#[0-9][\w-]*$/.test(account) ? account : "#1";
|
|
770
806
|
}
|
|
771
807
|
|
|
772
|
-
/** The per-account token row, tolerating the
|
|
808
|
+
/** The per-account token row, tolerating the "" key a scan cached while the base was unlabelled. */
|
|
773
809
|
function accountStats(stats: LocalFamilyStats | undefined, label: string): LocalAccountStats | undefined {
|
|
774
810
|
if (!stats?.byAccount) return undefined;
|
|
775
|
-
return stats.byAccount[label] ?? (label === "" ? stats.byAccount["
|
|
811
|
+
return stats.byAccount[label] ?? (label === "#1" ? stats.byAccount[""] : undefined);
|
|
776
812
|
}
|
|
777
813
|
|
|
778
814
|
/**
|
|
@@ -794,9 +830,8 @@ function renderAccountRows(accounts: QuotaProvider[], theme: Theme, stats?: Loca
|
|
|
794
830
|
const healthy = accounts.filter((account) => account.ok).length;
|
|
795
831
|
const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
|
|
796
832
|
const lines = [header];
|
|
797
|
-
//
|
|
798
|
-
|
|
799
|
-
const labels = accounts.map((account) => accountLabel(account.account));
|
|
833
|
+
// Labels are padded to the widest one ("#1-api"), so the percentage columns line up.
|
|
834
|
+
const labels = accounts.map((account) => accountLabel(account));
|
|
800
835
|
const width = Math.max(...labels.map((label) => label.length));
|
|
801
836
|
accounts.forEach((account, index) => {
|
|
802
837
|
const label = theme.fg("dim", labels[index].padEnd(width));
|
|
@@ -809,7 +844,9 @@ function renderAccountRows(accounts: QuotaProvider[], theme: Theme, stats?: Loca
|
|
|
809
844
|
const window = bindingWindow(account);
|
|
810
845
|
const used = usedOf(window);
|
|
811
846
|
if (window === null || used === null) {
|
|
812
|
-
|
|
847
|
+
// An account that answered without windows (an API key, Mistral without
|
|
848
|
+
// the usage permission) says why instead of a generic line.
|
|
849
|
+
lines.push(` ${label} ${theme.fg("dim", account.note ?? "no rate-limit windows reported")}`);
|
|
813
850
|
return;
|
|
814
851
|
}
|
|
815
852
|
// Every window on one line, each in its own band colour; the leading glyph
|
|
@@ -841,8 +878,9 @@ function renderProviderLines(provider: QuotaProvider, theme: Theme, localStats:
|
|
|
841
878
|
const lines: string[] = [];
|
|
842
879
|
const labelWidth = Math.max(6, ...provider.windows.map((window) => String(window.label ?? "").length));
|
|
843
880
|
const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
|
|
844
|
-
|
|
845
|
-
const
|
|
881
|
+
// A lone account is the family: its "#1" would say nothing.
|
|
882
|
+
const account = String(provider.account ?? "");
|
|
883
|
+
const meta: string[] = account === "" || account === "#1" ? [] : [account];
|
|
846
884
|
if (provider.plan) meta.push(`plan ${provider.plan}`);
|
|
847
885
|
// Two stores can back Claude, and this is the one place that says which.
|
|
848
886
|
if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
|
|
@@ -940,27 +978,6 @@ function renderPanel(report: QuotaReport, theme: Theme): string[] {
|
|
|
940
978
|
return [...sections.flatMap((section) => [section.header, ...section.body]), ...tail];
|
|
941
979
|
}
|
|
942
980
|
|
|
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
981
|
/** Pi reports the terminal height as a number; fall back to a usable frame. */
|
|
965
982
|
function terminalRows(value: number | undefined): number {
|
|
966
983
|
return Number.isFinite(value) ? Math.max(1, Math.floor(value as number)) : 24;
|
|
@@ -1036,9 +1053,33 @@ type HistoryFamily = {
|
|
|
1036
1053
|
models: Map<string, number>;
|
|
1037
1054
|
accounts: Map<string, { tokens: number; prompts: number; models: Map<string, number> }>;
|
|
1038
1055
|
allTime: number;
|
|
1039
|
-
|
|
1056
|
+
allTimePrompts: number;
|
|
1057
|
+
allTimeAccounts: Map<string, { tokens: number; prompts: number }>;
|
|
1058
|
+
/** The live day's delta, added to both the month and the all-time figures. */
|
|
1059
|
+
liveTokens: number;
|
|
1060
|
+
livePrompts: number;
|
|
1061
|
+
liveAccounts: Map<string, { tokens: number; prompts: number }>;
|
|
1040
1062
|
};
|
|
1041
1063
|
|
|
1064
|
+
/** An all-time figure, and whether the rollup behind it lags the month on screen. */
|
|
1065
|
+
type AllTimeFigure = { value: number; lags: boolean };
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* The all-time figure for one scope. All time contains every frozen day, so it
|
|
1069
|
+
* can never be smaller than a month of it: a smaller rollup means totals.json is
|
|
1070
|
+
* behind the month file — a freeze whose totals write failed, a month restored
|
|
1071
|
+
* behind the rollup. That is printed, dimmed and with a note, never hidden: the
|
|
1072
|
+
* figure is the only answer to "how much has this burned ever", and dropping it
|
|
1073
|
+
* silently is a worse lie than a number with a caveat.
|
|
1074
|
+
*/
|
|
1075
|
+
function allTimeFigure(month: number, allTime: number): AllTimeFigure {
|
|
1076
|
+
if (allTime <= 0) return { value: 0, lags: false };
|
|
1077
|
+
return { value: allTime, lags: allTime < month };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/** The note printed with an all-time figure that cannot cover the month on screen. */
|
|
1081
|
+
const ALL_TIME_LAGS_NOTE = "all-time lags this month; run piquota stats --rebuild-totals";
|
|
1082
|
+
|
|
1042
1083
|
/**
|
|
1043
1084
|
* What outlives one overlay instance. Every bare `/usage` builds a fresh view,
|
|
1044
1085
|
* so the folds and the page it was closed on live in the extension — for the pi
|
|
@@ -1080,15 +1121,19 @@ type UsageOverlayOptions = {
|
|
|
1080
1121
|
getRows: () => number;
|
|
1081
1122
|
/** The newest report the extension holds, or null before the first one. */
|
|
1082
1123
|
getReport: () => QuotaReport | null;
|
|
1083
|
-
/**
|
|
1084
|
-
|
|
1124
|
+
/**
|
|
1125
|
+
* Every family's refresh failure, one line each, in family order. A failure
|
|
1126
|
+
* is not cleared by another family's success: the families finish out of
|
|
1127
|
+
* order, and a single shared slot made the last arrival erase the others.
|
|
1128
|
+
*/
|
|
1129
|
+
getErrors: () => string[];
|
|
1085
1130
|
isRefreshing: () => boolean;
|
|
1086
1131
|
/** Run the same forced refresh `/usage` always ran, in the background. */
|
|
1087
1132
|
refresh: () => void;
|
|
1088
1133
|
/**
|
|
1089
|
-
* Ask the extension to
|
|
1090
|
-
*
|
|
1091
|
-
*
|
|
1134
|
+
* Ask the extension to read one month in-process (null: current month). The
|
|
1135
|
+
* request id comes back with the answer, so a slow read cannot apply a month
|
|
1136
|
+
* the last keypress did not ask for.
|
|
1092
1137
|
*/
|
|
1093
1138
|
loadHistory: (month: string | null, request: number) => void;
|
|
1094
1139
|
};
|
|
@@ -1099,6 +1144,7 @@ const FAMILY_LABEL: Record<string, string> = {
|
|
|
1099
1144
|
codex: "Codex (Pi)",
|
|
1100
1145
|
antigravity: "Antigravity (Pi)",
|
|
1101
1146
|
"opencode-go": "OpenCode Go (Pi)",
|
|
1147
|
+
mistral: "Mistral (Pi)",
|
|
1102
1148
|
"github-actions": "GitHub Actions",
|
|
1103
1149
|
};
|
|
1104
1150
|
|
|
@@ -1132,18 +1178,20 @@ const HISTORY_HINTS: ReadonlyArray<readonly [string, string]> = [
|
|
|
1132
1178
|
["esc/q", "close"],
|
|
1133
1179
|
];
|
|
1134
1180
|
|
|
1135
|
-
/** The report cache the CLI
|
|
1181
|
+
/** The report cache the CLI and in-process extension share. */
|
|
1136
1182
|
function reportCachePath(): string {
|
|
1137
1183
|
const cacheHome = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
|
|
1138
1184
|
return join(cacheHome, "pi-quota", "usage.json");
|
|
1139
1185
|
}
|
|
1140
1186
|
|
|
1141
1187
|
/**
|
|
1142
|
-
* The last report
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
1188
|
+
* The last report on disk, read synchronously so the overlay paints on the same
|
|
1189
|
+
* keystroke as /usage: read-only, age is irrelevant for a first frame, and a
|
|
1190
|
+
* missing or malformed cache is simply "no cached report". The key travels back
|
|
1191
|
+
* with it because it names the Claude source those numbers came from, and a
|
|
1192
|
+
* later partial write has to keep describing the same data.
|
|
1145
1193
|
*/
|
|
1146
|
-
function readCachedReport(): QuotaReport | null {
|
|
1194
|
+
function readCachedReport(): { report: QuotaReport; key: string | null } | null {
|
|
1147
1195
|
try {
|
|
1148
1196
|
const parsed = JSON.parse(readFileSync(reportCachePath(), "utf-8"));
|
|
1149
1197
|
const report = parsed?.report;
|
|
@@ -1157,7 +1205,10 @@ function readCachedReport(): QuotaReport | null {
|
|
|
1157
1205
|
if (!Number.isNaN(resetsAtMs)) window.resetsInSec = Math.max(0, Math.round((resetsAtMs - now) / 1000));
|
|
1158
1206
|
}
|
|
1159
1207
|
}
|
|
1160
|
-
return
|
|
1208
|
+
return {
|
|
1209
|
+
report: report as QuotaReport,
|
|
1210
|
+
key: typeof parsed?.key === "string" ? parsed.key : null,
|
|
1211
|
+
};
|
|
1161
1212
|
} catch {
|
|
1162
1213
|
return null;
|
|
1163
1214
|
}
|
|
@@ -1188,32 +1239,23 @@ function redactCause(value: string): string {
|
|
|
1188
1239
|
.replace(/\b((?:__Host-console_session|auth)=)[^;\s]+/gi, "$1<redacted>");
|
|
1189
1240
|
}
|
|
1190
1241
|
|
|
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
1242
|
/** A stderr line or a thrown message can quote a whole path list; the frame gets one line. */
|
|
1213
1243
|
function capCause(cause: string): string {
|
|
1214
1244
|
return cause.length > 160 ? `${cause.slice(0, 157)}…` : cause;
|
|
1215
1245
|
}
|
|
1216
1246
|
|
|
1247
|
+
/** Append warnings that are not already there: a repeated scan repeats its notes. */
|
|
1248
|
+
function mergeWarnings(existing: string[], added: string[] | undefined): string[] {
|
|
1249
|
+
const seen = new Set(existing);
|
|
1250
|
+
const merged = [...existing];
|
|
1251
|
+
for (const warning of added ?? []) {
|
|
1252
|
+
if (typeof warning !== "string" || warning === "" || seen.has(warning)) continue;
|
|
1253
|
+
seen.add(warning);
|
|
1254
|
+
merged.push(warning);
|
|
1255
|
+
}
|
|
1256
|
+
return merged;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1217
1259
|
/** Local YYYY-MM-DD, the history's day key. */
|
|
1218
1260
|
function localDayKey(now = new Date()): string {
|
|
1219
1261
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
@@ -1235,9 +1277,9 @@ function totalsModelTokens(bucket: HistoryTotalsModel | undefined): number {
|
|
|
1235
1277
|
return values.reduce((sum, value) => sum + Math.max(0, Math.round(Number(value) || 0)), 0);
|
|
1236
1278
|
}
|
|
1237
1279
|
|
|
1238
|
-
/** Slot labels sort ""
|
|
1280
|
+
/** Slot labels sort the base ("#1", or "" from older files) first, then #2, #3 … #10 numerically. */
|
|
1239
1281
|
function slotOrder(label: string): number {
|
|
1240
|
-
if (label === "") return -1;
|
|
1282
|
+
if (label === "" || label === "#1") return -1;
|
|
1241
1283
|
const numeric = Number.parseInt(label.replace(/^#/, ""), 10);
|
|
1242
1284
|
return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
|
|
1243
1285
|
}
|
|
@@ -1443,7 +1485,7 @@ export class UsageOverlay {
|
|
|
1443
1485
|
}
|
|
1444
1486
|
|
|
1445
1487
|
/**
|
|
1446
|
-
* A month (or the first payload, with the month list) arrived from the
|
|
1488
|
+
* A month (or the first payload, with the month list) arrived from the in-process reader.
|
|
1447
1489
|
* Every answer is cached — a later ← must not refetch what already arrived —
|
|
1448
1490
|
* but only the answer to the newest request may move the page: ←/→ overlap,
|
|
1449
1491
|
* and a late answer for a month the last keypress did not ask for used to snap
|
|
@@ -1481,21 +1523,32 @@ export class UsageOverlay {
|
|
|
1481
1523
|
this.options.requestRender();
|
|
1482
1524
|
}
|
|
1483
1525
|
|
|
1484
|
-
/**
|
|
1526
|
+
/**
|
|
1527
|
+
* Handle one raw key: switch view, fold, move, month, refresh, close.
|
|
1528
|
+
*
|
|
1529
|
+
* Keys arrive in the terminal's own encoding — legacy escape sequences or
|
|
1530
|
+
* kitty CSI sequences with an event type — so every test goes through
|
|
1531
|
+
* src/overlay-keys.js. A kitty release is the second half of a press the
|
|
1532
|
+
* terminal already reported: acting on it would move twice per keystroke.
|
|
1533
|
+
* Repeats stay presses, so a held arrow keeps moving as it does in tmux.
|
|
1534
|
+
*/
|
|
1485
1535
|
public handleInput(data: string): void {
|
|
1486
1536
|
if (this.disposed) return;
|
|
1487
|
-
if (data
|
|
1537
|
+
if (isKeyRelease(data)) return;
|
|
1538
|
+
if (matchesKey(data, "escape") || matchesPrintable(data, "q")) {
|
|
1488
1539
|
this.dispose();
|
|
1489
1540
|
this.options.done();
|
|
1490
1541
|
return;
|
|
1491
1542
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1543
|
+
const nowKey = matchesPrintable(data, "1");
|
|
1544
|
+
const historyKey = matchesPrintable(data, "2");
|
|
1545
|
+
if (matchesKey(data, "tab") || nowKey || historyKey) {
|
|
1546
|
+
const target = nowKey ? "now" : historyKey ? "history" : this.view === "now" ? "history" : "now";
|
|
1494
1547
|
this.switchView(target);
|
|
1495
1548
|
this.invalidate();
|
|
1496
1549
|
return;
|
|
1497
1550
|
}
|
|
1498
|
-
if (data
|
|
1551
|
+
if (matchesPrintable(data, "r")) {
|
|
1499
1552
|
// History's today row comes from the live report, so a refresh there
|
|
1500
1553
|
// reloads the month and the report together; one key, one meaning.
|
|
1501
1554
|
if (this.view === "history") this.reloadMonth();
|
|
@@ -1503,17 +1556,17 @@ export class UsageOverlay {
|
|
|
1503
1556
|
this.invalidate();
|
|
1504
1557
|
return;
|
|
1505
1558
|
}
|
|
1506
|
-
if (
|
|
1507
|
-
else if (
|
|
1508
|
-
else if (data
|
|
1509
|
-
else if (
|
|
1510
|
-
else if (
|
|
1511
|
-
else if (
|
|
1512
|
-
else if (
|
|
1513
|
-
else if (this.view === "history" && (
|
|
1514
|
-
else if (this.view === "history" && (
|
|
1515
|
-
else if (
|
|
1516
|
-
else if (
|
|
1559
|
+
if (matchesKey(data, "up") || matchesPrintable(data, "k")) this.moveCursor(-1);
|
|
1560
|
+
else if (matchesKey(data, "down") || matchesPrintable(data, "j")) this.moveCursor(1);
|
|
1561
|
+
else if (matchesKey(data, "enter") || matchesKey(data, "space")) this.toggleSection();
|
|
1562
|
+
else if (matchesKey(data, "pageUp")) this.page(-1);
|
|
1563
|
+
else if (matchesKey(data, "pageDown")) this.page(1);
|
|
1564
|
+
else if (matchesKey(data, "home") || matchesPrintable(data, "g")) this.jumpTo(0);
|
|
1565
|
+
else if (matchesKey(data, "end") || matchesPrintable(data, "G")) this.jumpTo(-1);
|
|
1566
|
+
else if (this.view === "history" && (matchesKey(data, "left") || matchesPrintable(data, "["))) this.moveMonth(-1);
|
|
1567
|
+
else if (this.view === "history" && (matchesKey(data, "right") || matchesPrintable(data, "]"))) this.moveMonth(1);
|
|
1568
|
+
else if (matchesKey(data, "left") || matchesPrintable(data, "h")) this.setExpanded(false);
|
|
1569
|
+
else if (matchesKey(data, "right") || matchesPrintable(data, "l")) this.setExpanded(true);
|
|
1517
1570
|
else return;
|
|
1518
1571
|
// The TUI renders the component again after every key, so the cached frame
|
|
1519
1572
|
// and the cached row map must be dropped here, not only on report changes.
|
|
@@ -1620,8 +1673,9 @@ export class UsageOverlay {
|
|
|
1620
1673
|
this.pushRow(rows, ` ${this.theme.fg("error", `! ${failure}`)}${hint}`, -1, false);
|
|
1621
1674
|
}
|
|
1622
1675
|
} else {
|
|
1623
|
-
const error
|
|
1624
|
-
|
|
1676
|
+
for (const error of this.options.getErrors()) {
|
|
1677
|
+
rows.push({ text: ` ${this.theme.fg("error", `! ${error}`)}`, section: -1, header: false });
|
|
1678
|
+
}
|
|
1625
1679
|
}
|
|
1626
1680
|
if (!layout) {
|
|
1627
1681
|
// Distinct states, distinct lines: a failed fetch used to say "loading
|
|
@@ -1819,7 +1873,19 @@ export class UsageOverlay {
|
|
|
1819
1873
|
const ensure = (family: string): HistoryFamily => {
|
|
1820
1874
|
let entry = families.get(family);
|
|
1821
1875
|
if (!entry) {
|
|
1822
|
-
entry = {
|
|
1876
|
+
entry = {
|
|
1877
|
+
tokens: 0,
|
|
1878
|
+
prompts: 0,
|
|
1879
|
+
days: new Map(),
|
|
1880
|
+
models: new Map(),
|
|
1881
|
+
accounts: new Map(),
|
|
1882
|
+
allTime: 0,
|
|
1883
|
+
allTimePrompts: 0,
|
|
1884
|
+
allTimeAccounts: new Map(),
|
|
1885
|
+
liveTokens: 0,
|
|
1886
|
+
livePrompts: 0,
|
|
1887
|
+
liveAccounts: new Map(),
|
|
1888
|
+
};
|
|
1823
1889
|
families.set(family, entry);
|
|
1824
1890
|
}
|
|
1825
1891
|
return entry;
|
|
@@ -1833,7 +1899,7 @@ export class UsageOverlay {
|
|
|
1833
1899
|
entry.prompts += Math.max(0, Math.round(Number(day?.prompts) || 0));
|
|
1834
1900
|
entry.days.set(date, (entry.days.get(date) ?? 0) + tokens);
|
|
1835
1901
|
for (const [slot, slotRow] of Object.entries(day?.slots ?? {})) {
|
|
1836
|
-
const label = slot === "" ? "" : `#${slot}`;
|
|
1902
|
+
const label = slot === "" ? "#1" : `#${slot}`;
|
|
1837
1903
|
const account = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
|
|
1838
1904
|
account.tokens += Math.max(0, Math.round(Number(slotRow?.tokens) || 0));
|
|
1839
1905
|
account.prompts += Math.max(0, Math.round(Number(slotRow?.prompts) || 0));
|
|
@@ -1884,15 +1950,23 @@ export class UsageOverlay {
|
|
|
1884
1950
|
const entry = ensure(family);
|
|
1885
1951
|
entry.tokens += tokens;
|
|
1886
1952
|
entry.prompts += prompts;
|
|
1953
|
+
// Kept apart from the frozen month: the all-time figures add this same
|
|
1954
|
+
// delta below, and a live day may only ever be counted once in each.
|
|
1955
|
+
entry.liveTokens += tokens;
|
|
1956
|
+
entry.livePrompts += prompts;
|
|
1887
1957
|
entry.days.set(today, (entry.days.get(today) ?? 0) + tokens);
|
|
1888
1958
|
for (const [model, modelTokens] of Object.entries(familyStats.todayTokensByModel ?? {})) {
|
|
1889
1959
|
entry.models.set(model, (entry.models.get(model) ?? 0) + Math.max(0, Math.round(Number(modelTokens) || 0)));
|
|
1890
1960
|
}
|
|
1891
1961
|
for (const [rawLabel, account] of Object.entries(familyStats.byAccount ?? {})) {
|
|
1892
|
-
const label = rawLabel === "
|
|
1962
|
+
const label = rawLabel === "" ? "#1" : rawLabel;
|
|
1893
1963
|
const target = entry.accounts.get(label) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
|
|
1894
|
-
|
|
1895
|
-
|
|
1964
|
+
const accountTokens = Math.max(0, Math.round(Number(account?.todayTokens) || 0));
|
|
1965
|
+
const accountPrompts = Math.max(0, Math.round(Number(account?.todayPrompts) || 0));
|
|
1966
|
+
target.tokens += accountTokens;
|
|
1967
|
+
target.prompts += accountPrompts;
|
|
1968
|
+
const liveAccount = entry.liveAccounts.get(label) ?? { tokens: 0, prompts: 0 };
|
|
1969
|
+
entry.liveAccounts.set(label, { tokens: liveAccount.tokens + accountTokens, prompts: liveAccount.prompts + accountPrompts });
|
|
1896
1970
|
// The account's own models for today: without them the merged row
|
|
1897
1971
|
// shows a month total and no model, which reads as "no usage".
|
|
1898
1972
|
for (const [model, modelTokens] of Object.entries(account?.todayTokensByModel ?? {})) {
|
|
@@ -1906,10 +1980,33 @@ export class UsageOverlay {
|
|
|
1906
1980
|
for (const [family, totals] of Object.entries(payload.totals?.families ?? {})) {
|
|
1907
1981
|
const entry = ensure(family);
|
|
1908
1982
|
entry.allTime = Object.values(totals?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
|
|
1983
|
+
entry.allTimePrompts = Math.max(0, Math.round(Number(totals?.totalPrompts) || 0));
|
|
1909
1984
|
for (const [rawLabel, account] of Object.entries(totals?.accounts ?? {})) {
|
|
1910
|
-
const label = rawLabel === "
|
|
1985
|
+
const label = rawLabel === "" ? "#1" : rawLabel;
|
|
1911
1986
|
const tokens = Object.values(account?.modelUsage ?? {}).reduce((sum, bucket) => sum + totalsModelTokens(bucket), 0);
|
|
1912
|
-
|
|
1987
|
+
const prompts = Math.max(0, Math.round(Number(account?.totalPrompts) || 0));
|
|
1988
|
+
const seen = entry.allTimeAccounts.get(label) ?? { tokens: 0, prompts: 0 };
|
|
1989
|
+
entry.allTimeAccounts.set(label, { tokens: seen.tokens + tokens, prompts: seen.prompts + prompts });
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Today's live day is part of all time, not only of this month: it is the
|
|
1994
|
+
// same usage the month row and the day row above already show, so it is
|
|
1995
|
+
// added here exactly once — the single place the live delta is folded into
|
|
1996
|
+
// a rollup, and only under the merge guard above (the day frozen, or a
|
|
1997
|
+
// report from another day, never produces a delta at all).
|
|
1998
|
+
//
|
|
1999
|
+
// A family with no rollup in totals.json gets nothing: there is no all-time
|
|
2000
|
+
// figure to extend, and calling today's tokens "all-time" would hide every
|
|
2001
|
+
// frozen day before it. The page already carries the CLI's note for that.
|
|
2002
|
+
for (const [family, entry] of families) {
|
|
2003
|
+
if (!payload.totals?.families?.[family]) continue;
|
|
2004
|
+
if (entry.liveTokens === 0 && entry.livePrompts === 0) continue;
|
|
2005
|
+
entry.allTime += entry.liveTokens;
|
|
2006
|
+
entry.allTimePrompts += entry.livePrompts;
|
|
2007
|
+
for (const [label, delta] of entry.liveAccounts) {
|
|
2008
|
+
const seen = entry.allTimeAccounts.get(label) ?? { tokens: 0, prompts: 0 };
|
|
2009
|
+
entry.allTimeAccounts.set(label, { tokens: seen.tokens + delta.tokens, prompts: seen.prompts + delta.prompts });
|
|
1913
2010
|
}
|
|
1914
2011
|
}
|
|
1915
2012
|
|
|
@@ -1936,10 +2033,29 @@ export class UsageOverlay {
|
|
|
1936
2033
|
// Multi-account families use the same short name as the Now panel header.
|
|
1937
2034
|
const display = accountLabels.length > 1 ? SHORT_NAME[family] ?? FAMILY_LABEL[family] ?? family : FAMILY_LABEL[family] ?? family;
|
|
1938
2035
|
const name = paint(BRAND[family] ?? "#8B949E", display);
|
|
2036
|
+
// The month first, then the all-time rollup with today's live day folded
|
|
2037
|
+
// in: every figure here counts a live day once. Prompts are scope-labelled
|
|
2038
|
+
// in their own row below, because an unlabelled count reads as the wrong
|
|
2039
|
+
// one of the two.
|
|
2040
|
+
//
|
|
2041
|
+
// Every all-time figure is computed before the first line is built: the
|
|
2042
|
+
// note a lagging rollup earns belongs at the top of the section, where it
|
|
2043
|
+
// explains the header, the prompts row and the account rows at once.
|
|
1939
2044
|
const meta = [`${formatTokens(entry.tokens)} in ${month}`];
|
|
1940
|
-
|
|
1941
|
-
if (
|
|
2045
|
+
const familyAllTime = allTimeFigure(entry.tokens, entry.allTime);
|
|
2046
|
+
if (familyAllTime.value > 0) meta.push(`${formatTokens(familyAllTime.value)} all-time`);
|
|
2047
|
+
const promptsAllTime = allTimeFigure(entry.prompts, entry.allTimePrompts);
|
|
2048
|
+
const accountAllTime = new Map<string, AllTimeFigure>();
|
|
2049
|
+
for (const accountLabel of accountLabels) {
|
|
2050
|
+
accountAllTime.set(
|
|
2051
|
+
accountLabel,
|
|
2052
|
+
allTimeFigure(entry.accounts.get(accountLabel)?.tokens ?? 0, entry.allTimeAccounts.get(accountLabel)?.tokens ?? 0),
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
const lagsMonth =
|
|
2056
|
+
familyAllTime.lags || promptsAllTime.lags || Array.from(accountAllTime.values()).some((figure) => figure.lags);
|
|
1942
2057
|
const body: string[] = [];
|
|
2058
|
+
if (lagsMonth) body.push(` ${this.theme.fg("warning", `~ ${ALL_TIME_LAGS_NOTE}`)}`);
|
|
1943
2059
|
const label = (text: string) => ` ${this.theme.fg("dim", text.padEnd(6))} `;
|
|
1944
2060
|
const rest = ` ${" ".repeat(8)}`;
|
|
1945
2061
|
const dayCells = Array.from(entry.days.entries())
|
|
@@ -1951,6 +2067,22 @@ export class UsageOverlay {
|
|
|
1951
2067
|
rest,
|
|
1952
2068
|
bodyWidth,
|
|
1953
2069
|
));
|
|
2070
|
+
// Both scopes, always: the live day is merged into each of them once, so
|
|
2071
|
+
// the month count and the all-time count may never disagree about it.
|
|
2072
|
+
const promptCells: string[] = [];
|
|
2073
|
+
if (entry.prompts > 0) promptCells.push(`${entry.prompts} in ${month}`);
|
|
2074
|
+
if (promptsAllTime.value > 0) {
|
|
2075
|
+
// A figure that lags the month is still a figure: it is printed, dimmed
|
|
2076
|
+
// whole, and the note above the rows says why it is small.
|
|
2077
|
+
promptCells.push(
|
|
2078
|
+
promptsAllTime.lags
|
|
2079
|
+
? this.theme.fg("dim", `${promptsAllTime.value} all-time`)
|
|
2080
|
+
: `${promptsAllTime.value} ${this.theme.fg("dim", "all-time")}`,
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
if (promptCells.length > 0) {
|
|
2084
|
+
body.push(...wrapCells(promptCells, label("prompts"), rest, bodyWidth));
|
|
2085
|
+
}
|
|
1954
2086
|
if (entry.models.size > 0) {
|
|
1955
2087
|
const top = Array.from(entry.models.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
1956
2088
|
const shown = top.slice(0, 5).map(([model, tokens]) => `${shortModelName(model)} ${formatTokens(tokens)}`);
|
|
@@ -1962,8 +2094,14 @@ export class UsageOverlay {
|
|
|
1962
2094
|
for (const accountLabel of accountLabels) {
|
|
1963
2095
|
const account = entry.accounts.get(accountLabel) ?? { tokens: 0, prompts: 0, models: new Map<string, number>() };
|
|
1964
2096
|
const parts = [`${this.theme.fg("dim", "month")} ${formatTokens(account.tokens)}`];
|
|
1965
|
-
const
|
|
1966
|
-
if (
|
|
2097
|
+
const accountFigure = accountAllTime.get(accountLabel) ?? { value: 0, lags: false };
|
|
2098
|
+
if (accountFigure.value > 0) {
|
|
2099
|
+
parts.push(
|
|
2100
|
+
accountFigure.lags
|
|
2101
|
+
? this.theme.fg("dim", `all-time ${formatTokens(accountFigure.value)}`)
|
|
2102
|
+
: `${this.theme.fg("dim", "all-time")} ${formatTokens(accountFigure.value)}`,
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
1967
2105
|
const best = Array.from(account.models.entries()).sort((a, b) => b[1] - a[1])[0];
|
|
1968
2106
|
if (best) parts.push(this.theme.fg("dim", shortModelName(best[0])));
|
|
1969
2107
|
body.push(` ${this.theme.fg("dim", accountLabel.padEnd(labelWidth))} ${parts.join(this.theme.fg("dim", " · "))}`);
|
|
@@ -2008,7 +2146,7 @@ export class UsageOverlay {
|
|
|
2008
2146
|
} else {
|
|
2009
2147
|
const report = this.options.getReport();
|
|
2010
2148
|
if (report) parts.push(this.theme.fg("dim", updatedAtLabel(report)));
|
|
2011
|
-
if (this.options.
|
|
2149
|
+
if (this.options.getErrors().length > 0) parts.push(this.theme.fg("error", "refresh failed"));
|
|
2012
2150
|
}
|
|
2013
2151
|
if (this.view === "history") {
|
|
2014
2152
|
const status = this.historyStatus;
|
|
@@ -2055,9 +2193,34 @@ export class UsageOverlay {
|
|
|
2055
2193
|
}
|
|
2056
2194
|
}
|
|
2057
2195
|
|
|
2058
|
-
export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
2196
|
+
export default function quotaPanelExtension(pi: ExtensionAPI, dependencies: ExtensionDependencies = {}): void {
|
|
2197
|
+
const loadFamily = dependencies.collectFamily ?? collectFamily;
|
|
2198
|
+
const startScan = dependencies.startLocalStats ?? (() => startLocalStatsWorker() as LocalStatsScan);
|
|
2199
|
+
const getProviderAuth = dependencies.getProviderAuth ?? (async (ctx, provider) => ctx.modelRegistry?.getProviderAuth?.(provider));
|
|
2200
|
+
const readHistory = (month: string | null) => dependencies.loadHistory
|
|
2201
|
+
? dependencies.loadHistory(month)
|
|
2202
|
+
: (historyJson({ historyDir: resolveHistoryDir(), month }) as HistoryPayload);
|
|
2203
|
+
const publishOmarchy = dependencies.publishOmarchy ?? ((value, prune) => {
|
|
2204
|
+
const skip = skippedFamilies(process.env);
|
|
2205
|
+
const reportToPublish = skip.size > 0
|
|
2206
|
+
? { ...value, providers: value.providers.filter((provider) => !skip.has(provider.family)) }
|
|
2207
|
+
: value;
|
|
2208
|
+
publishRecords(buildRecords(reportToPublish as any), { prune });
|
|
2209
|
+
});
|
|
2059
2210
|
let report: QuotaReport | null = null;
|
|
2060
|
-
|
|
2211
|
+
const inFlight = new Map<string, Promise<boolean>>();
|
|
2212
|
+
let resultGeneration = 0;
|
|
2213
|
+
let prunedGeneration = 0;
|
|
2214
|
+
let localStatsInFlight: Promise<void> | null = null;
|
|
2215
|
+
let localStatsScan: LocalStatsScan | null = null;
|
|
2216
|
+
let localStatsResult: Awaited<LocalStatsScan["result"]> | null = null;
|
|
2217
|
+
/**
|
|
2218
|
+
* The notes the scan produced (its own warnings, or the one line a failure
|
|
2219
|
+
* becomes). They live here, not only in the report, because the scan can
|
|
2220
|
+
* finish before the first family does: it is the report that is late then,
|
|
2221
|
+
* not the warning.
|
|
2222
|
+
*/
|
|
2223
|
+
let localStatsWarnings: string[] = [];
|
|
2061
2224
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
2062
2225
|
let agentEndTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2063
2226
|
let panelVisible = false;
|
|
@@ -2080,22 +2243,30 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2080
2243
|
let explicitBox = false;
|
|
2081
2244
|
/** gentle-pi owns the footer and truncates it from the end, so start off. */
|
|
2082
2245
|
let statusVisible = false;
|
|
2083
|
-
|
|
2246
|
+
/** One failure per family: an out-of-order success must not hide another's. */
|
|
2247
|
+
const familyErrors = new Map<string, string>();
|
|
2248
|
+
/**
|
|
2249
|
+
* The Claude source the report in memory actually came from. The extension
|
|
2250
|
+
* forces Pi's own OAuth when Pi resolves a token, and that is a different
|
|
2251
|
+
* fact from what PI_QUOTA_CLAUDE_SOURCE says: keying the cache by the setting
|
|
2252
|
+
* alone stored Pi-sourced numbers under the CLI's auto/claude-code key, where
|
|
2253
|
+
* the next reader asked the other question and was served them anyway.
|
|
2254
|
+
*/
|
|
2255
|
+
let claudeSourceUsed: string | null = null;
|
|
2084
2256
|
let disposed = false;
|
|
2085
2257
|
/** True once the installed gentle-pi is known to paint a rail slot for this card. */
|
|
2086
2258
|
let railMode = false;
|
|
2087
2259
|
|
|
2088
2260
|
/**
|
|
2089
|
-
* Load the rail inspector from
|
|
2090
|
-
* native-layout detection; the runtime chaining
|
|
2261
|
+
* Load the rail inspector from this package, not from a quota CLI subprocess.
|
|
2262
|
+
* A missing patcher disables native-layout detection; the runtime chaining
|
|
2263
|
+
* hook remains available.
|
|
2091
2264
|
*/
|
|
2092
2265
|
async function loadRailPatcher(): Promise<{
|
|
2093
2266
|
resolveGentlePiLayout: (options?: { dir?: string }) => { layoutPath: string; present: boolean };
|
|
2094
2267
|
inspectRailPatch: (options: { layoutPath: string }) => { state: string };
|
|
2095
2268
|
} | null> {
|
|
2096
|
-
const
|
|
2097
|
-
if (!cli) return null;
|
|
2098
|
-
const modulePath = join(dirname(cli), "..", "src", "gentle-pi", "rail-patch.js");
|
|
2269
|
+
const modulePath = dependencies.railPatcherPath ?? fileURLToPath(new URL("../src/gentle-pi/rail-patch.js", import.meta.url));
|
|
2099
2270
|
if (!existsSync(modulePath)) return null;
|
|
2100
2271
|
try {
|
|
2101
2272
|
return await import(pathToFileURL(modulePath).href);
|
|
@@ -2115,35 +2286,211 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2115
2286
|
railMode = inspection.state === "patched";
|
|
2116
2287
|
}
|
|
2117
2288
|
|
|
2289
|
+
/** Whether a family refresh is in flight; the header shows it while one is. */
|
|
2290
|
+
function isRefreshing(): boolean {
|
|
2291
|
+
return inFlight.size > 0;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/** Resolve Pi's own auth for the two rotating OAuth providers. Tokens stay local to this call. */
|
|
2295
|
+
async function authOverridesFor(ctx: UiContext, family: string): Promise<Record<string, { access: string; expiresAtMs: number | null; accountId?: string }> | undefined> {
|
|
2296
|
+
const provider = family === "claude" ? "anthropic" : family === "codex" ? "openai-codex" : null;
|
|
2297
|
+
if (!provider) return undefined;
|
|
2298
|
+
try {
|
|
2299
|
+
const resolved = await getProviderAuth(ctx, provider);
|
|
2300
|
+
// Pi can resolve api_key credentials through the same method. Quota
|
|
2301
|
+
// endpoints here require the subscription OAuth access token, not an API
|
|
2302
|
+
// key; only Pi's OAuth result is eligible for the in-memory override.
|
|
2303
|
+
if (resolved?.source !== "OAuth") return undefined;
|
|
2304
|
+
const token = resolved.auth?.apiKey;
|
|
2305
|
+
if (typeof token !== "string" || token.length === 0) return undefined;
|
|
2306
|
+
const accountId = (typeof resolved?.accountId === "string" && resolved.accountId) || extractChatGptAccountId(token) || undefined;
|
|
2307
|
+
return {
|
|
2308
|
+
[provider]: {
|
|
2309
|
+
access: token,
|
|
2310
|
+
expiresAtMs: extractExpiresAtMs(token),
|
|
2311
|
+
...(family === "codex" && accountId ? { accountId } : {}),
|
|
2312
|
+
},
|
|
2313
|
+
};
|
|
2314
|
+
} catch {
|
|
2315
|
+
// Never put arbitrary auth errors in a TUI frame; the engine will report its read-only credential state.
|
|
2316
|
+
return undefined;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
function mergeFamilyReport(fragment: QuotaReport): void {
|
|
2321
|
+
const prior = report;
|
|
2322
|
+
const merged = mergeReportIntoCache(prior, fragment as any) as QuotaReport;
|
|
2323
|
+
merged.localStats = localStatsResult?.byFamily ?? prior?.localStats ?? fragment.localStats ?? {};
|
|
2324
|
+
merged.localStatsMeta = localStatsResult?.scan ?? prior?.localStatsMeta ?? fragment.localStatsMeta ?? null;
|
|
2325
|
+
merged.warnings = mergeWarnings(merged.warnings ?? [], localStatsWarnings);
|
|
2326
|
+
const families = enabledFamilies({ env: process.env, requested: FAMILIES });
|
|
2327
|
+
const ordering = [...families, ...Object.keys(merged.byFamily).filter((family) => !families.includes(family))];
|
|
2328
|
+
merged.providers = ordering.flatMap((family) => merged.providers.filter((provider) => provider.family === family));
|
|
2329
|
+
merged.byFamily = Object.fromEntries(
|
|
2330
|
+
ordering
|
|
2331
|
+
.map((family) => [family, merged.providers.filter((provider) => provider.family === family)] as const)
|
|
2332
|
+
.filter(([, providers]) => providers.length > 0),
|
|
2333
|
+
);
|
|
2334
|
+
report = merged;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2118
2337
|
/**
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2338
|
+
* The report in memory, or the one on disk. Reading the cache also adopts the
|
|
2339
|
+
* Claude source its key names, so a codex-only write keeps describing the
|
|
2340
|
+
* Claude numbers the file already holds.
|
|
2121
2341
|
*/
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
const
|
|
2125
|
-
if (!
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2342
|
+
function ensureReport(): QuotaReport | null {
|
|
2343
|
+
if (report) return report;
|
|
2344
|
+
const cached = readCachedReport();
|
|
2345
|
+
if (!cached) return null;
|
|
2346
|
+
const stored = cached.key?.startsWith(CLAUDE_SOURCE_CACHE_PREFIX)
|
|
2347
|
+
? cached.key.slice(CLAUDE_SOURCE_CACHE_PREFIX.length)
|
|
2348
|
+
: null;
|
|
2349
|
+
if (stored) claudeSourceUsed = normalizeClaudeSourceMode(stored);
|
|
2350
|
+
report = cached.report;
|
|
2351
|
+
return report;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/** The key this report belongs under: the source it used, not the setting. */
|
|
2355
|
+
function cacheKey(): string {
|
|
2356
|
+
return cacheKeyFor(claudeSourceUsed ?? process.env.PI_QUOTA_CLAUDE_SOURCE ?? "");
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
function persistReport(freshFamilies: string[] = []): void {
|
|
2360
|
+
if (!report) return;
|
|
2361
|
+
// Every family keeps the clock it was fetched on: this report is written one
|
|
2362
|
+
// family at a time, and stamping the whole file with "now" made Claude's
|
|
2363
|
+
// five-minute-old numbers look as fresh as the Codex ones saved beside them.
|
|
2364
|
+
// Only the family that just resolved is "now"; a family without a clock in
|
|
2365
|
+
// refresh-state keeps the cache's, or its own results' age.
|
|
2366
|
+
writeCache(report as any, { key: cacheKey(), fetchedAtMs: readRefreshState().fetchedAtMs, freshFamilies });
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
function updateFamilyClock(family: string): void {
|
|
2370
|
+
const state = readRefreshState();
|
|
2371
|
+
writeRefreshState({ fetchedAtMs: { ...state.fetchedAtMs, [family]: Date.now() } });
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function startLocalStats(ctx: UiContext): void {
|
|
2375
|
+
if (process.env.PI_QUOTA_LOCAL_STATS === "0" || localStatsInFlight) return;
|
|
2376
|
+
const scan = startScan();
|
|
2377
|
+
localStatsScan = scan;
|
|
2378
|
+
const task = scan.result
|
|
2379
|
+
.then((result) => {
|
|
2380
|
+
if (disposed) return;
|
|
2381
|
+
localStatsResult = result;
|
|
2382
|
+
// The scan's own notes (a corrupt cache, an unreadable file) are part of
|
|
2383
|
+
// the report the CLI already publishes; dropping them here made the same
|
|
2384
|
+
// scan quieter inside Pi than in the terminal.
|
|
2385
|
+
localStatsWarnings = result.warnings ?? [];
|
|
2386
|
+
if (report) {
|
|
2387
|
+
report = {
|
|
2388
|
+
...report,
|
|
2389
|
+
localStats: result.byFamily,
|
|
2390
|
+
localStatsMeta: result.scan,
|
|
2391
|
+
warnings: mergeWarnings(report.warnings, localStatsWarnings),
|
|
2392
|
+
};
|
|
2393
|
+
persistReport();
|
|
2394
|
+
if (surfaceActive()) paintUi(ctx);
|
|
2395
|
+
try {
|
|
2396
|
+
publishOmarchy(report, false);
|
|
2397
|
+
} catch {
|
|
2398
|
+
// The quota view is independent of the optional Omarchy record writer.
|
|
2399
|
+
}
|
|
2400
|
+
overlayView?.redraw();
|
|
2401
|
+
}
|
|
2402
|
+
})
|
|
2403
|
+
.catch((error: { name?: string; message?: string }) => {
|
|
2404
|
+
// The scan is optional, so a failure never delays or erases quotas — but
|
|
2405
|
+
// it is not the same as a scan that found nothing. The CLI reports a
|
|
2406
|
+
// failed scan as one warning line; a swallowed worker error meant a
|
|
2407
|
+
// crash in the scanner looked exactly like an empty token history.
|
|
2408
|
+
if (disposed || error?.name === "AbortError") return;
|
|
2409
|
+
const cause = capCause(redactCause(error?.message ?? String(error)).split("\n")[0]);
|
|
2410
|
+
localStatsWarnings = mergeWarnings(localStatsWarnings, [`local token stats: ${cause}`]);
|
|
2411
|
+
if (!report) return;
|
|
2412
|
+
report = { ...report, warnings: mergeWarnings(report.warnings, localStatsWarnings) };
|
|
2413
|
+
if (surfaceActive()) paintUi(ctx);
|
|
2414
|
+
overlayView?.redraw();
|
|
2415
|
+
})
|
|
2416
|
+
.finally(() => {
|
|
2417
|
+
if (localStatsScan === scan) localStatsScan = null;
|
|
2418
|
+
if (localStatsInFlight === task) localStatsInFlight = null;
|
|
2419
|
+
});
|
|
2420
|
+
localStatsInFlight = task;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
/**
|
|
2424
|
+
* End the local scan this session started. Nothing it began may outlive the
|
|
2425
|
+
* session: a /quit or /reload one second into a cold scan would otherwise
|
|
2426
|
+
* keep reading the session store and hold the token-history lock until the
|
|
2427
|
+
* 120 s stale timeout, making the next scan read-only.
|
|
2428
|
+
*/
|
|
2429
|
+
async function stopLocalStats(): Promise<void> {
|
|
2430
|
+
const scan = localStatsScan;
|
|
2431
|
+
localStatsScan = null;
|
|
2432
|
+
if (!scan) return;
|
|
2433
|
+
await scan.stop();
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
async function refreshFamily(ctx: UiContext, family: string, force: boolean): Promise<boolean> {
|
|
2437
|
+
const running = inFlight.get(family);
|
|
2438
|
+
if (running) return running;
|
|
2439
|
+
let task: Promise<boolean>;
|
|
2440
|
+
task = (async () => {
|
|
2441
|
+
try {
|
|
2442
|
+
// Explicit delay seam used by the live progressive-refresh proof; absent in normal runs.
|
|
2443
|
+
if (process.env.PI_QUOTA_DEBUG_DELAY_FAMILY === family) {
|
|
2444
|
+
const delay = Math.max(0, Math.min(30_000, Number(process.env.PI_QUOTA_DEBUG_DELAY_MS) || 2_000));
|
|
2445
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
2446
|
+
}
|
|
2447
|
+
const authOverrides = await authOverridesFor(ctx, family);
|
|
2448
|
+
const env = family === "claude" && authOverrides
|
|
2449
|
+
? { ...process.env, PI_QUOTA_CLAUDE_SOURCE: "pi" }
|
|
2450
|
+
: process.env;
|
|
2451
|
+
const fragment = await loadFamily(family, { force, authOverrides, env, localStats: false } as any);
|
|
2452
|
+
if (disposed) return false;
|
|
2453
|
+
if (family === "claude") claudeSourceUsed = normalizeClaudeSourceMode(env.PI_QUOTA_CLAUDE_SOURCE);
|
|
2454
|
+
const stickyFragment = mergeLastGood(fragment as any, {}).report as QuotaReport;
|
|
2455
|
+
mergeFamilyReport(stickyFragment);
|
|
2456
|
+
updateFamilyClock(family);
|
|
2457
|
+
persistReport([family]);
|
|
2458
|
+
resultGeneration += 1;
|
|
2459
|
+
familyErrors.delete(family);
|
|
2460
|
+
try {
|
|
2461
|
+
const familyReport = { ...stickyFragment, localStats: report?.localStats ?? stickyFragment.localStats ?? {} };
|
|
2462
|
+
publishOmarchy(familyReport, false);
|
|
2463
|
+
} catch {
|
|
2464
|
+
// A bar-record failure must not turn a quota refresh into an error.
|
|
2465
|
+
}
|
|
2466
|
+
if (surfaceActive()) paintUi(ctx);
|
|
2467
|
+
return true;
|
|
2468
|
+
} catch (error) {
|
|
2469
|
+
const message = (error as { message?: string })?.message ?? String(error);
|
|
2470
|
+
familyErrors.set(family, capCause(redactCause(message).split("\n")[0]));
|
|
2471
|
+
return false;
|
|
2472
|
+
} finally {
|
|
2473
|
+
if (inFlight.get(family) === task) inFlight.delete(family);
|
|
2474
|
+
if (surfaceActive()) paintUi(ctx);
|
|
2475
|
+
overlayView?.redraw();
|
|
2140
2476
|
}
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2477
|
+
})();
|
|
2478
|
+
inFlight.set(family, task);
|
|
2479
|
+
return task;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* One line per failing family, in family order so the list does not reshuffle
|
|
2484
|
+
* as families land. The name is short for the same reason: this is a row in a
|
|
2485
|
+
* dense panel, not a log.
|
|
2486
|
+
*/
|
|
2487
|
+
function errorLines(): string[] {
|
|
2488
|
+
const order = [...enabledFamilies({ env: process.env, requested: FAMILIES })];
|
|
2489
|
+
for (const family of familyErrors.keys()) if (!order.includes(family)) order.push(family);
|
|
2490
|
+
return order.flatMap((family) => {
|
|
2491
|
+
const message = familyErrors.get(family);
|
|
2492
|
+
return message ? [`${SHORT_NAME[family] ?? family}: ${message}`] : [];
|
|
2493
|
+
});
|
|
2147
2494
|
}
|
|
2148
2495
|
|
|
2149
2496
|
function paintUi(ctx: UiContext): void {
|
|
@@ -2294,21 +2641,42 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2294
2641
|
}
|
|
2295
2642
|
|
|
2296
2643
|
async function refresh(ctx: UiContext, force: boolean): Promise<void> {
|
|
2297
|
-
if (
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2644
|
+
if (disposed) return;
|
|
2645
|
+
report = ensureReport();
|
|
2646
|
+
const families = enabledFamilies({ env: process.env, requested: FAMILIES });
|
|
2647
|
+
const now = Date.now();
|
|
2648
|
+
const fetchedAtMs = { ...readRefreshState().fetchedAtMs };
|
|
2649
|
+
// Upgrade path: an older CLI cache has no per-family clock file, but each
|
|
2650
|
+
// provider already carries its own updatedAt. Do not refetch fresh cached data
|
|
2651
|
+
// merely because this extension version has not written clocks yet.
|
|
2652
|
+
for (const family of families) {
|
|
2653
|
+
if (typeof fetchedAtMs[family] === "number") continue;
|
|
2654
|
+
const providerTimes = (report?.providers ?? [])
|
|
2655
|
+
.filter((provider) => provider.family === family)
|
|
2656
|
+
.map((provider) => Date.parse(provider.updatedAt))
|
|
2657
|
+
.filter((time) => Number.isFinite(time));
|
|
2658
|
+
const generatedAt = report?.generatedAt ? Date.parse(report.generatedAt) : NaN;
|
|
2659
|
+
const fallback = providerTimes.length > 0 ? Math.max(...providerTimes) : generatedAt;
|
|
2660
|
+
if (Number.isFinite(fallback)) fetchedAtMs[family] = fallback;
|
|
2661
|
+
}
|
|
2662
|
+
const staleByClock = new Set(staleFamilies({
|
|
2663
|
+
fetchedAtMs,
|
|
2664
|
+
ttls: resolveFamilyTtls({ families }),
|
|
2665
|
+
families,
|
|
2666
|
+
now,
|
|
2667
|
+
}));
|
|
2668
|
+
const cachedFamilies = new Set(report?.providers.map((provider) => provider.family) ?? []);
|
|
2669
|
+
const selected = force ? families : families.filter((family) => !cachedFamilies.has(family) || staleByClock.has(family));
|
|
2670
|
+
if (selected.length === 0) return;
|
|
2671
|
+
startLocalStats(ctx);
|
|
2672
|
+
const outcomes = await Promise.all(selected.map((family) => refreshFamily(ctx, family, force)));
|
|
2673
|
+
if (outcomes.every(Boolean) && inFlight.size === 0 && report && resultGeneration !== prunedGeneration) {
|
|
2674
|
+
try {
|
|
2675
|
+
publishOmarchy(report, true);
|
|
2676
|
+
prunedGeneration = resultGeneration;
|
|
2677
|
+
} catch {
|
|
2678
|
+
// Keep fresh quota data even if the optional final stale-record prune fails.
|
|
2308
2679
|
}
|
|
2309
|
-
} finally {
|
|
2310
|
-
refreshing = false;
|
|
2311
|
-
overlayView?.redraw();
|
|
2312
2680
|
}
|
|
2313
2681
|
}
|
|
2314
2682
|
|
|
@@ -2368,36 +2736,23 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2368
2736
|
ui.ui.setStatus(STATUS_KEY, undefined);
|
|
2369
2737
|
ui.ui.setWidget(WIDGET_KEY, undefined);
|
|
2370
2738
|
}
|
|
2739
|
+
// Pi awaits session_shutdown (reason "reload" included), so the scan is gone
|
|
2740
|
+
// and its lock released before the next session or the process starts.
|
|
2741
|
+
await stopLocalStats();
|
|
2371
2742
|
});
|
|
2372
2743
|
|
|
2373
2744
|
/**
|
|
2374
|
-
*
|
|
2375
|
-
*
|
|
2745
|
+
* Read one month (null: the current local month) in-process and hand the
|
|
2746
|
+
* aggregate-only payload to the overlay. Read-only, no lock: historyJson only reads.
|
|
2376
2747
|
*
|
|
2377
|
-
* A failure is
|
|
2378
|
-
* stderr line or its exit status, redacted and on one line. The JSON parser's
|
|
2748
|
+
* A reader failure is redacted and limited to one line. The JSON parser's
|
|
2379
2749
|
* "Unexpected end of JSON input" says nothing about the cause.
|
|
2380
2750
|
*/
|
|
2381
2751
|
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
2752
|
try {
|
|
2388
|
-
const
|
|
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
|
-
}
|
|
2753
|
+
const parsed = await readHistory(month);
|
|
2399
2754
|
if (!parsed || !Array.isArray(parsed.months)) {
|
|
2400
|
-
overlayView?.setHistoryError(
|
|
2755
|
+
overlayView?.setHistoryError("piquota stats returned no history payload", request);
|
|
2401
2756
|
return;
|
|
2402
2757
|
}
|
|
2403
2758
|
overlayView?.setHistory(parsed, request);
|
|
@@ -2414,9 +2769,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2414
2769
|
*/
|
|
2415
2770
|
async function showOverlay(ctx: UiContext): Promise<void> {
|
|
2416
2771
|
// Paint on the same keystroke: the report already in memory, otherwise the
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
|
|
2772
|
+
// on-disk cache. Only stale families refresh behind the overlay; the first
|
|
2773
|
+
// frame never waits for the network.
|
|
2774
|
+
report = ensureReport();
|
|
2420
2775
|
const custom = ctx.ui.custom;
|
|
2421
2776
|
if (typeof custom !== "function" || (ctx.mode !== undefined && ctx.mode !== "tui")) {
|
|
2422
2777
|
// A host without custom components still gets the panel that /usage panel pins.
|
|
@@ -2424,7 +2779,8 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2424
2779
|
lineVisible = false;
|
|
2425
2780
|
boxVisible = false;
|
|
2426
2781
|
explicitBox = false;
|
|
2427
|
-
|
|
2782
|
+
report = ensureReport();
|
|
2783
|
+
void refresh(ctx, false);
|
|
2428
2784
|
ensurePolling(ctx);
|
|
2429
2785
|
paintUi(ctx);
|
|
2430
2786
|
ctx.ui.notify("This host has no fullscreen overlay; the quota panel is pinned above the editor", "info");
|
|
@@ -2437,8 +2793,8 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2437
2793
|
requestRender: () => tui?.requestRender?.(),
|
|
2438
2794
|
getRows: () => tui?.terminal?.rows ?? process.stdout.rows ?? 24,
|
|
2439
2795
|
getReport: () => report,
|
|
2440
|
-
|
|
2441
|
-
isRefreshing: () =>
|
|
2796
|
+
getErrors: () => errorLines(),
|
|
2797
|
+
isRefreshing: () => isRefreshing(),
|
|
2442
2798
|
refresh: () => {
|
|
2443
2799
|
void refresh(ctx, true);
|
|
2444
2800
|
},
|
|
@@ -2474,10 +2830,11 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2474
2830
|
switch (action) {
|
|
2475
2831
|
case "refresh":
|
|
2476
2832
|
await refresh(ctx, true);
|
|
2833
|
+
const failures = errorLines();
|
|
2477
2834
|
ctx.ui.notify(
|
|
2478
2835
|
report
|
|
2479
2836
|
? `Quota refreshed (${report.providers.filter((provider) => provider.ok).length}/${report.providers.length} providers)`
|
|
2480
|
-
: `Quota refresh failed: ${
|
|
2837
|
+
: `Quota refresh failed: ${failures.length > 1 ? `${failures[0]} (+${failures.length - 1} more)` : failures[0] ?? "unknown error"}`,
|
|
2481
2838
|
report ? "info" : "error",
|
|
2482
2839
|
);
|
|
2483
2840
|
return;
|
|
@@ -2487,7 +2844,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2487
2844
|
boxVisible = false;
|
|
2488
2845
|
explicitBox = false;
|
|
2489
2846
|
panelVisible = false;
|
|
2847
|
+
report = ensureReport();
|
|
2490
2848
|
if (!report) await refresh(ctx, false);
|
|
2849
|
+
else void refresh(ctx, false);
|
|
2491
2850
|
ensurePolling(ctx);
|
|
2492
2851
|
paintUi(ctx);
|
|
2493
2852
|
ctx.ui.notify("Quota line pinned above the editor", "info");
|
|
@@ -2497,7 +2856,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2497
2856
|
lineVisible = false;
|
|
2498
2857
|
boxVisible = false;
|
|
2499
2858
|
explicitBox = false;
|
|
2859
|
+
report = ensureReport();
|
|
2500
2860
|
if (!report) await refresh(ctx, false);
|
|
2861
|
+
else void refresh(ctx, false);
|
|
2501
2862
|
ensurePolling(ctx);
|
|
2502
2863
|
paintUi(ctx);
|
|
2503
2864
|
ctx.ui.notify("Quota panel shown above the editor", "info");
|
|
@@ -2507,7 +2868,9 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2507
2868
|
boxVisible = true;
|
|
2508
2869
|
explicitBox = true;
|
|
2509
2870
|
panelVisible = false;
|
|
2871
|
+
report = ensureReport();
|
|
2510
2872
|
if (!report) await refresh(ctx, false);
|
|
2873
|
+
else void refresh(ctx, false);
|
|
2511
2874
|
ensurePolling(ctx);
|
|
2512
2875
|
paintUi(ctx);
|
|
2513
2876
|
ctx.ui.notify("Quota box pinned above the editor", "info");
|
|
@@ -2534,7 +2897,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2534
2897
|
paintUi(ctx);
|
|
2535
2898
|
return;
|
|
2536
2899
|
case "json":
|
|
2537
|
-
ctx.ui.notify(
|
|
2900
|
+
ctx.ui.notify(`Report cache: ${reportCachePath()}`, "info");
|
|
2538
2901
|
return;
|
|
2539
2902
|
default:
|
|
2540
2903
|
break;
|
|
@@ -2543,7 +2906,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
2543
2906
|
// Bare /usage (or an unknown argument): open the overlay with whatever the
|
|
2544
2907
|
// extension already has, and refresh in the background behind it.
|
|
2545
2908
|
const opening = showOverlay(ctx);
|
|
2546
|
-
void refresh(ctx,
|
|
2909
|
+
void refresh(ctx, false);
|
|
2547
2910
|
await opening;
|
|
2548
2911
|
};
|
|
2549
2912
|
|