pi-sub-quota 0.1.1 → 0.1.2
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/README.md +6 -5
- package/cache.ts +25 -2
- package/index.ts +425 -100
- package/package.json +1 -1
- package/view.ts +8 -5
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ Restart pi (or run `/reload`). There is nothing to configure: the extension reus
|
|
|
24
24
|
|
|
25
25
|
| Command | Effect |
|
|
26
26
|
| --- | --- |
|
|
27
|
-
| `/quota` | quota for every configured provider,
|
|
28
|
-
| `/quota refresh` |
|
|
27
|
+
| `/quota` | cached quota for every configured provider, instantly — stale numbers refresh in the background and the panel updates when they land |
|
|
28
|
+
| `/quota refresh` | force a re-fetch for the active provider now (at most one request per provider per minute, machine-wide, plus one immediate in-cycle retry when a request fails) |
|
|
29
29
|
| `/quota mode inline\|line\|off` | how — and whether — the footer shows quota |
|
|
30
30
|
| `/quota help` | list options |
|
|
31
31
|
|
|
@@ -72,7 +72,7 @@ kimi-coding coding plan · Allegretto · booster ¥100.00/¥200.00
|
|
|
72
72
|
/quota refresh · /quota mode line|inline|off · /quota help
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
The footer shows how much you have used; the panel shows what is left, as a meter and a percentage, with each reset as a concrete local time (`13:47` today, `Fri 15:00` this week, `Sep 01` later).
|
|
75
|
+
The footer shows how much you have used; the panel shows what is left, as a meter and a percentage, with each reset as a concrete local time (`13:47` today, `Fri 15:00` this week, `Sep 01` later). The panel never waits on the network: whatever is cached renders the moment you press Enter — old numbers carry a `~`, missing ones read `fetching…` — and a second panel replaces it once the refreshed numbers land.
|
|
76
76
|
|
|
77
77
|
## Supported providers
|
|
78
78
|
|
|
@@ -86,8 +86,9 @@ Custom providers work without configuration too: detection falls back to the mod
|
|
|
86
86
|
|
|
87
87
|
## Good to know
|
|
88
88
|
|
|
89
|
-
- Numbers refresh at most every five minutes;
|
|
90
|
-
-
|
|
89
|
+
- Numbers refresh while you work — on model switches and on every agent run — but at most once every five minutes per provider; an idle session, mode `off`, and non-TUI sessions (RPC, JSON) send no requests at all.
|
|
90
|
+
- Each provider endpoint is asked at most once per minute, machine-wide: every attempt (even a failed one) persists its timestamp in the shared cache before the request starts, so several pi sessions keep their distance too. A failed request earns a single immediate retry inside its cycle; when a fetch keeps failing, the `/quota` panel says so on the provider's own line — never as an interrupting notification.
|
|
91
|
+
- A failed refresh never breaks anything: the last numbers stay, marked `~`, until the next success — and `/quota` says why a provider failed (`quota unavailable (HTTP 401)`) or when it will retry (`retry in 42s`).
|
|
91
92
|
- Several pi sessions on the same machine share one cache safely.
|
|
92
93
|
- A lapsed Kimi OAuth token is skipped with a note — it refreshes the next time pi itself calls Kimi.
|
|
93
94
|
- Credentials come only from pi's own store; no environment variables are read, and a key can never be sent to the wrong host.
|
package/cache.ts
CHANGED
|
@@ -11,6 +11,12 @@ export const CACHE_VERSION = 2;
|
|
|
11
11
|
export interface QuotaCacheFile {
|
|
12
12
|
version: number;
|
|
13
13
|
snapshots: Partial<Record<ProviderKind, QuotaSnapshot>>;
|
|
14
|
+
/**
|
|
15
|
+
* Last fetch attempt per provider, in ms epoch. Persisted — not kept in
|
|
16
|
+
* memory — so every pi session on the machine shares one rate limit and a
|
|
17
|
+
* failing endpoint is retried at most once per interval machine-wide.
|
|
18
|
+
*/
|
|
19
|
+
lastRequests?: Partial<Record<ProviderKind, number>>;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export function emptyCache(): QuotaCacheFile {
|
|
@@ -84,7 +90,7 @@ export function parseCache(text: string | undefined): QuotaCacheFile {
|
|
|
84
90
|
try {
|
|
85
91
|
const raw: unknown = JSON.parse(text);
|
|
86
92
|
if (typeof raw !== "object" || raw === null) return emptyCache();
|
|
87
|
-
const file = raw as { version?: unknown; snapshots?: unknown };
|
|
93
|
+
const file = raw as { version?: unknown; snapshots?: unknown; lastRequests?: unknown };
|
|
88
94
|
if (file.version !== CACHE_VERSION || typeof file.snapshots !== "object" || file.snapshots === null) {
|
|
89
95
|
return emptyCache();
|
|
90
96
|
}
|
|
@@ -94,7 +100,19 @@ export function parseCache(text: string | undefined): QuotaCacheFile {
|
|
|
94
100
|
snapshots[kind as ProviderKind] = snapshot;
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
|
-
|
|
103
|
+
// Attempt times drive the request rate limit; an invalid entry simply
|
|
104
|
+
// does not limit — it must never block parsing of the snapshots.
|
|
105
|
+
const lastRequests: Partial<Record<ProviderKind, number>> = {};
|
|
106
|
+
if (typeof file.lastRequests === "object" && file.lastRequests !== null) {
|
|
107
|
+
for (const [kind, at] of Object.entries(file.lastRequests)) {
|
|
108
|
+
if ((PROVIDER_KINDS as readonly string[]).includes(kind) && typeof at === "number" && Number.isFinite(at)) {
|
|
109
|
+
lastRequests[kind as ProviderKind] = at;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const cache: QuotaCacheFile = { version: CACHE_VERSION, snapshots };
|
|
114
|
+
if (Object.keys(lastRequests).length > 0) cache.lastRequests = lastRequests;
|
|
115
|
+
return cache;
|
|
98
116
|
} catch {
|
|
99
117
|
return emptyCache();
|
|
100
118
|
}
|
|
@@ -125,3 +143,8 @@ export function isNewerCacheFile(text: string | undefined): boolean {
|
|
|
125
143
|
export function freshSnapshot(snapshot: QuotaSnapshot | undefined, now: number, ttlMs: number): snapshot is QuotaSnapshot {
|
|
126
144
|
return snapshot !== undefined && now - snapshot.fetchedAt < ttlMs;
|
|
127
145
|
}
|
|
146
|
+
|
|
147
|
+
/** True when a request may be sent: none was recorded, or the minimum interval since the last attempt has passed. */
|
|
148
|
+
export function requestDue(lastRequestAt: number | undefined, now: number, minIntervalMs: number): boolean {
|
|
149
|
+
return lastRequestAt === undefined || now - lastRequestAt >= minIntervalMs;
|
|
150
|
+
}
|
package/index.ts
CHANGED
|
@@ -20,10 +20,25 @@
|
|
|
20
20
|
* quota provider is active and a snapshot with at least one item exists.
|
|
21
21
|
* With no data at all (missing API key, empty cache, failed fetch) — and in
|
|
22
22
|
* off mode, or after switching to a provider without quota — the built-in
|
|
23
|
-
* footer stays completely untouched. Snapshots
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* footer stays completely untouched. Snapshots live in
|
|
24
|
+
* `<agent-dir>/quota-cache.json` with a five-minute TTL, shared (and
|
|
25
|
+
* written atomically) by every pi session on the machine.
|
|
26
|
+
*
|
|
27
|
+
* Fetches never run on a wall clock; they happen on demand and on activity:
|
|
28
|
+
* - `/quota` renders every configured provider from cache instantly —
|
|
29
|
+
* stale snapshots keep their numbers behind a `~` — then refreshes the
|
|
30
|
+
* stale ones in the background and re-renders once when data landed;
|
|
31
|
+
* - `/quota refresh` force-refreshes the active provider;
|
|
32
|
+
* - agent runs (`agent_start` / `agent_end`) and model switches refresh
|
|
33
|
+
* the active provider, TTL-gated — an idle session, mode off, and
|
|
34
|
+
* non-TUI sessions (RPC, JSON, print) send no requests at all.
|
|
35
|
+
* Each provider endpoint is asked at most once per minute machine-wide:
|
|
36
|
+
* every attempt, successful or not, persists `lastRequests` in the cache
|
|
37
|
+
* file before the request starts, so concurrent sessions and failing
|
|
38
|
+
* endpoints keep their distance too (a failed request earns one immediate
|
|
39
|
+
* retry inside its cycle). When a refresh fails, the last snapshot is kept
|
|
40
|
+
* with a warning `~` prefix, and the failure itself is explained inside the
|
|
41
|
+
* /quota panel — never as an interrupting notification.
|
|
27
42
|
*
|
|
28
43
|
* Display modes (persisted in `<agent-dir>/quota-settings.json`):
|
|
29
44
|
* line dedicated quota line under the stats with labeled meters and
|
|
@@ -33,8 +48,9 @@
|
|
|
33
48
|
* off restore the built-in footer entirely
|
|
34
49
|
*
|
|
35
50
|
* Commands:
|
|
36
|
-
* /quota
|
|
37
|
-
*
|
|
51
|
+
* /quota cached quota for every configured provider, instantly;
|
|
52
|
+
* refreshed in the background and re-rendered when done
|
|
53
|
+
* /quota refresh force a re-fetch for the active provider (once per minute at most)
|
|
38
54
|
* /quota mode line|inline|off change display mode (persisted)
|
|
39
55
|
* /quota help list options
|
|
40
56
|
*/
|
|
@@ -44,15 +60,34 @@ import { homedir } from "node:os";
|
|
|
44
60
|
import { join } from "node:path";
|
|
45
61
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
46
62
|
|
|
47
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
emptyCache,
|
|
65
|
+
freshSnapshot,
|
|
66
|
+
isNewerCacheFile,
|
|
67
|
+
parseCache,
|
|
68
|
+
type QuotaCacheFile,
|
|
69
|
+
requestDue,
|
|
70
|
+
serializeCache,
|
|
71
|
+
} from "./cache.ts";
|
|
48
72
|
import { createQuotaFooter } from "./footer.ts";
|
|
49
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
type ConfiguredQuotaProvider,
|
|
75
|
+
configuredQuotaProviders,
|
|
76
|
+
detectProvider,
|
|
77
|
+
type ProviderKind,
|
|
78
|
+
type QuotaProvider,
|
|
79
|
+
type QuotaSnapshot,
|
|
80
|
+
} from "./providers/index.ts";
|
|
50
81
|
import { DEFAULT_MODE, MODES, parseSettings, type QuotaMode, serializeSettings } from "./settings.ts";
|
|
51
82
|
import { formatQuota, formatQuotaPanel, type QuotaPanelRow } from "./view.ts";
|
|
52
83
|
|
|
53
84
|
const CACHE_TTL_MS = 300_000;
|
|
54
85
|
const TICK_MS = 60_000;
|
|
55
86
|
const FETCH_TIMEOUT_MS = 8000;
|
|
87
|
+
/** No provider endpoint is asked more often than this, machine-wide. */
|
|
88
|
+
const MIN_REQUEST_INTERVAL_MS = 60_000;
|
|
89
|
+
/** Pause before the single retry a failed fetch earns inside its cycle. */
|
|
90
|
+
const FETCH_RETRY_DELAY_MS = 500;
|
|
56
91
|
|
|
57
92
|
/** The model fields detection needs; structurally compatible with pi's Model. */
|
|
58
93
|
interface ActiveModel {
|
|
@@ -67,6 +102,19 @@ let activeProviderId: string | undefined;
|
|
|
67
102
|
let activeBaseUrl: string | undefined;
|
|
68
103
|
let currentMode: QuotaMode | undefined;
|
|
69
104
|
let refreshing = false;
|
|
105
|
+
/**
|
|
106
|
+
* Serializes refresh cycles end-to-end: a call that arrives while a cycle is
|
|
107
|
+
* open waits for it and then runs its own. A manual /quota refresh can then
|
|
108
|
+
* never be dropped by (or interleave with) the boot or activity refresh — it
|
|
109
|
+
* queues behind it and reports the machine-wide rate limit instead of racing.
|
|
110
|
+
*/
|
|
111
|
+
let refreshQueue: Promise<void> = Promise.resolve();
|
|
112
|
+
/**
|
|
113
|
+
* Last fetch failure per provider, remembered for the /quota panel: it
|
|
114
|
+
* explains missing-or-stale numbers on the panel's own surface until a
|
|
115
|
+
* fetch succeeds again, instead of interrupting the user with a notification.
|
|
116
|
+
*/
|
|
117
|
+
const lastErrors = new Map<ProviderKind, { message: string; at: number }>();
|
|
70
118
|
/** True when the shown snapshot predates a failed refresh. */
|
|
71
119
|
let stale = false;
|
|
72
120
|
let currentSnapshot: QuotaSnapshot | undefined;
|
|
@@ -75,6 +123,8 @@ let footerInstalled = false;
|
|
|
75
123
|
let activeCtx: ExtensionContext | undefined;
|
|
76
124
|
/** Listeners notified whenever the snapshot, staleness, or mode changes (footer re-renders). */
|
|
77
125
|
const snapshotListeners = new Set<() => void>();
|
|
126
|
+
/** Last fetch attempt per provider in this process — the cache file shares them across sessions. */
|
|
127
|
+
const lastAttempts = new Map<ProviderKind, number>();
|
|
78
128
|
|
|
79
129
|
function publishSnapshot(): void {
|
|
80
130
|
for (const listener of snapshotListeners) listener();
|
|
@@ -91,11 +141,19 @@ function oauthExpired(entry: unknown): boolean {
|
|
|
91
141
|
return typeof expires === "number" && expires <= Date.now() + 30_000;
|
|
92
142
|
}
|
|
93
143
|
|
|
144
|
+
/** True when the auth entry carries a credential a fetch could use: an unexpired OAuth access token or an API key. */
|
|
145
|
+
function hasUsableCredential(entry: unknown): boolean {
|
|
146
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
147
|
+
if (oauthExpired(entry)) return false;
|
|
148
|
+
const { access, key } = entry as { access?: unknown; key?: unknown };
|
|
149
|
+
return typeof (access ?? key) === "string";
|
|
150
|
+
}
|
|
151
|
+
|
|
94
152
|
/**
|
|
95
153
|
* Read the credential for a provider from pi's auth.json: the OAuth access
|
|
96
154
|
* token (`access`) for OAuth providers such as kimi-coding, the API `key`
|
|
97
155
|
* otherwise. The file is re-read on every fetch so a token pi refreshed
|
|
98
|
-
* mid-session is picked up on the next
|
|
156
|
+
* mid-session is picked up on the next request. A lapsed OAuth token counts as
|
|
99
157
|
* no credential — pi only refreshes it when it calls the provider itself,
|
|
100
158
|
* so requesting with it would be a guaranteed 401.
|
|
101
159
|
*/
|
|
@@ -152,8 +210,23 @@ async function saveMode(mode: QuotaMode): Promise<void> {
|
|
|
152
210
|
publishSnapshot();
|
|
153
211
|
}
|
|
154
212
|
|
|
155
|
-
/**
|
|
156
|
-
|
|
213
|
+
/** The cache write in flight, if any; in-process writers queue behind it. */
|
|
214
|
+
let cacheWriteChain: Promise<void> = Promise.resolve();
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Read-modify-write with a rename, so concurrent pi sessions never read a
|
|
218
|
+
* torn file. Writes are serialized in-process first: the /quota panel
|
|
219
|
+
* refreshes several providers in parallel, and two interleaved
|
|
220
|
+
* read-modify-write cycles would silently drop whichever update renamed
|
|
221
|
+
* first. performCacheWrite never rejects, so the chain cannot break.
|
|
222
|
+
*/
|
|
223
|
+
async function writeCacheFile(update: (cache: QuotaCacheFile) => void): Promise<void> {
|
|
224
|
+
const write = cacheWriteChain.then(() => performCacheWrite(update));
|
|
225
|
+
cacheWriteChain = write;
|
|
226
|
+
await write;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function performCacheWrite(update: (cache: QuotaCacheFile) => void): Promise<void> {
|
|
157
230
|
const path = join(agentDir(), "quota-cache.json");
|
|
158
231
|
try {
|
|
159
232
|
const text = await readFile(path, "utf8").catch(() => undefined);
|
|
@@ -161,7 +234,7 @@ async function writeCacheSnapshot(snapshot: QuotaSnapshot): Promise<void> {
|
|
|
161
234
|
// this version would silently discard whatever that format stored.
|
|
162
235
|
if (isNewerCacheFile(text)) return;
|
|
163
236
|
const cache = parseCache(text);
|
|
164
|
-
cache
|
|
237
|
+
update(cache);
|
|
165
238
|
// A unique tmp name per writer: two sessions sharing one fixed tmp path
|
|
166
239
|
// could interleave writes into the same file and rename a torn result.
|
|
167
240
|
const tmp = `${path}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`;
|
|
@@ -172,34 +245,136 @@ async function writeCacheSnapshot(snapshot: QuotaSnapshot): Promise<void> {
|
|
|
172
245
|
}
|
|
173
246
|
}
|
|
174
247
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
248
|
+
/** Store a fetched snapshot, keeping every other provider's data and attempt times. */
|
|
249
|
+
async function writeCacheSnapshot(snapshot: QuotaSnapshot): Promise<void> {
|
|
250
|
+
await writeCacheFile((cache) => {
|
|
251
|
+
cache.snapshots[snapshot.kind] = snapshot;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Persist a fetch-attempt time so every pi session on the machine shares one rate limit. */
|
|
256
|
+
async function writeCacheLastRequest(kind: ProviderKind, now: number): Promise<void> {
|
|
257
|
+
await writeCacheFile((cache) => {
|
|
258
|
+
cache.lastRequests = { ...cache.lastRequests, [kind]: now };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** One provider's fetch outcome, including why no fresh data arrived. */
|
|
263
|
+
type FetchOutcome =
|
|
264
|
+
| { status: "fetched"; snapshot: QuotaSnapshot }
|
|
265
|
+
| { status: "limited" }
|
|
266
|
+
| { status: "no-credential" }
|
|
267
|
+
| { status: "error"; message: string };
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Reserve the next request slot for a provider: true when at least
|
|
271
|
+
* MIN_REQUEST_INTERVAL_MS has passed since any session last attempted a
|
|
272
|
+
* fetch. The attempt time is persisted before the request starts, so even
|
|
273
|
+
* concurrent sessions and failing endpoints keep at least a minute between
|
|
274
|
+
* attempts machine-wide. Two bounded excesses remain: a read-write race can
|
|
275
|
+
* admit one duplicate reservation, and a failed attempt earns one immediate
|
|
276
|
+
* in-cycle retry — both are extra status requests only, never data ones.
|
|
277
|
+
*/
|
|
278
|
+
async function reserveFetchSlot(kind: ProviderKind): Promise<boolean> {
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
const local = lastAttempts.get(kind);
|
|
281
|
+
if (local !== undefined && !requestDue(local, now, MIN_REQUEST_INTERVAL_MS)) return false;
|
|
282
|
+
const persisted = (await readCacheFile()).lastRequests?.[kind];
|
|
283
|
+
if (typeof persisted === "number" && !requestDue(persisted, now, MIN_REQUEST_INTERVAL_MS)) {
|
|
284
|
+
// Adopt the other session's timestamp so this process counts down from it.
|
|
285
|
+
lastAttempts.set(kind, Math.max(persisted, local ?? 0));
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
lastAttempts.set(kind, now);
|
|
289
|
+
await writeCacheLastRequest(kind, now);
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Fetch one provider's quota and cache the snapshot, honoring the
|
|
295
|
+
* machine-wide request interval. Credentials come exclusively from pi's
|
|
296
|
+
* auth.json: the same credential pi itself runs the provider with, so no
|
|
297
|
+
* environment fallback can pair a key with a foreign host.
|
|
298
|
+
*/
|
|
299
|
+
async function fetchAndCache(provider: QuotaProvider, providerId: string | undefined, baseUrl?: string): Promise<FetchOutcome> {
|
|
183
300
|
const apiKey = await readAuthKey(providerId);
|
|
184
|
-
if (!apiKey) return
|
|
185
|
-
|
|
301
|
+
if (!apiKey) return { status: "no-credential" };
|
|
302
|
+
if (!(await reserveFetchSlot(provider.kind))) return { status: "limited" };
|
|
303
|
+
// One retry: quota endpoints blip (5xx, connection resets) and a single
|
|
304
|
+
// immediate re-request usually clears it. The slot this cycle reserved
|
|
305
|
+
// covers both tries, so the machine-wide interval still bounds the pair.
|
|
306
|
+
for (let attempt = 0; ; attempt++) {
|
|
307
|
+
try {
|
|
308
|
+
const snapshot = await provider.fetchQuota(apiKey, { now: Date.now, timeoutMs: FETCH_TIMEOUT_MS, baseUrl });
|
|
309
|
+
lastErrors.delete(provider.kind);
|
|
310
|
+
await writeCacheSnapshot(snapshot);
|
|
311
|
+
return { status: "fetched", snapshot };
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
314
|
+
if (attempt < 1) {
|
|
315
|
+
await new Promise((resolve) => setTimeout(resolve, FETCH_RETRY_DELAY_MS));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Expected while offline or on provider errors: keep showing the
|
|
319
|
+
// last snapshot; the /quota panel explains the outage on its own surface.
|
|
320
|
+
lastErrors.set(provider.kind, { message, at: Date.now() });
|
|
321
|
+
return { status: "error", message };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
186
324
|
}
|
|
187
325
|
|
|
188
|
-
|
|
326
|
+
/** What a refresh cycle produced for the active provider. */
|
|
327
|
+
interface RefreshResult {
|
|
328
|
+
/** Latest snapshot for the active provider, from memory or cache. */
|
|
329
|
+
snapshot?: QuotaSnapshot;
|
|
330
|
+
/** True when this call brought back fresh numbers. */
|
|
331
|
+
fetched: boolean;
|
|
332
|
+
/** True when the request was skipped: some session asked within the last minute. */
|
|
333
|
+
limited: boolean;
|
|
334
|
+
/** Set when this cycle's own fetch failed (after its retry); the panel, not a notification, shows it. */
|
|
335
|
+
error?: string;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function refresh(opts: { force?: boolean } = {}): Promise<RefreshResult> {
|
|
339
|
+
// Capture the active provider here, not when the queued cycle starts: a
|
|
340
|
+
// model switch between the two would pair the cycle with the wrong (or no)
|
|
341
|
+
// provider, and a manual refresh must report the provider it was asked about.
|
|
189
342
|
const provider = activeProvider;
|
|
190
|
-
if (!provider
|
|
343
|
+
if (!provider) return Promise.resolve({ snapshot: currentSnapshot, fetched: false, limited: false });
|
|
344
|
+
const providerId = activeProviderId;
|
|
345
|
+
const baseUrl = activeBaseUrl;
|
|
346
|
+
const cycle = refreshQueue.then(() => runRefreshCycle(provider, providerId, baseUrl, opts));
|
|
347
|
+
// The queue itself never rejects, or one failed cycle would poison every later one.
|
|
348
|
+
refreshQueue = cycle.then(
|
|
349
|
+
() => undefined,
|
|
350
|
+
() => undefined,
|
|
351
|
+
);
|
|
352
|
+
return cycle;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function runRefreshCycle(
|
|
356
|
+
provider: QuotaProvider,
|
|
357
|
+
providerId: string | undefined,
|
|
358
|
+
baseUrl: string | undefined,
|
|
359
|
+
opts: { force?: boolean },
|
|
360
|
+
): Promise<RefreshResult> {
|
|
191
361
|
/** True when the model switched to another provider since this refresh started. */
|
|
192
362
|
const switchedProvider = () => provider.kind !== activeProvider?.kind;
|
|
193
363
|
refreshing = true;
|
|
364
|
+
let fetched = false;
|
|
365
|
+
let limited = false;
|
|
366
|
+
let fetchError: string | undefined;
|
|
367
|
+
/** The snapshot this cycle fetched, if any — reported even when a mid-fetch model switch keeps it out of the footer. */
|
|
368
|
+
let fetchedSnapshot: QuotaSnapshot | undefined;
|
|
194
369
|
try {
|
|
195
|
-
if (!force) {
|
|
370
|
+
if (!opts.force) {
|
|
196
371
|
const cached = await readCacheFile().then((cache) => cache.snapshots[provider.kind]);
|
|
197
372
|
if (!switchedProvider() && freshSnapshot(cached, Date.now(), CACHE_TTL_MS)) {
|
|
198
373
|
currentSnapshot = cached;
|
|
199
374
|
stale = false;
|
|
200
375
|
await syncFooterInstall();
|
|
201
376
|
publishSnapshot();
|
|
202
|
-
return currentSnapshot;
|
|
377
|
+
return { snapshot: currentSnapshot, fetched: false, limited: false, error: undefined };
|
|
203
378
|
}
|
|
204
379
|
// Show the last snapshot of any age while a refresh is in flight, so the
|
|
205
380
|
// footer never waits on the network with nothing to display.
|
|
@@ -210,16 +385,12 @@ async function refresh(force: boolean): Promise<QuotaSnapshot | undefined> {
|
|
|
210
385
|
publishSnapshot();
|
|
211
386
|
}
|
|
212
387
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// last snapshot (marked stale below) instead of logging — pi shows
|
|
220
|
-
// extension stderr to the user, and a dropped status fetch is not
|
|
221
|
-
// an error worth interrupting anyone for.
|
|
222
|
-
}
|
|
388
|
+
const outcome = await fetchAndCache(provider, providerId, baseUrl);
|
|
389
|
+
limited = outcome.status === "limited";
|
|
390
|
+
fetchError = outcome.status === "error" ? outcome.message : undefined;
|
|
391
|
+
fetchedSnapshot = outcome.status === "fetched" ? outcome.snapshot : undefined;
|
|
392
|
+
fetched = fetchedSnapshot !== undefined;
|
|
393
|
+
let next = fetchedSnapshot;
|
|
223
394
|
// The model switched mid-fetch: the result belongs to the previous
|
|
224
395
|
// provider and must never bleed into the new provider's prefix.
|
|
225
396
|
if (next && switchedProvider()) next = undefined;
|
|
@@ -231,8 +402,22 @@ async function refresh(force: boolean): Promise<QuotaSnapshot | undefined> {
|
|
|
231
402
|
currentSnapshot = await readCacheFile().then((cache) => cache.snapshots[provider.kind]);
|
|
232
403
|
stale = true;
|
|
233
404
|
} else if (currentSnapshot) {
|
|
234
|
-
// Keep showing the last snapshot
|
|
235
|
-
|
|
405
|
+
// Keep showing the last snapshot. A failed refresh marks it stale; a
|
|
406
|
+
// rate-limited or credential-less skip only when it is past the TTL.
|
|
407
|
+
// A limited skip sent nothing, and this cycle may have waited in the
|
|
408
|
+
// queue first: another session can have fetched meanwhile, so adopt the
|
|
409
|
+
// machine-wide cache's snapshot when it is newer than memory. Memory is
|
|
410
|
+
// captured before the await — sync() can clear currentSnapshot while the
|
|
411
|
+
// file is read — and the switch is re-checked after it so an old
|
|
412
|
+
// provider's numbers never bleed into the new one's memory.
|
|
413
|
+
if (outcome.status === "limited" && !switchedProvider()) {
|
|
414
|
+
const memory = currentSnapshot;
|
|
415
|
+
if (memory) {
|
|
416
|
+
const machine = await readCacheFile().then((cache) => cache.snapshots[provider.kind]);
|
|
417
|
+
if (machine && machine.fetchedAt > memory.fetchedAt && !switchedProvider()) currentSnapshot = machine;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
stale = outcome.status === "error" || !freshSnapshot(currentSnapshot, Date.now(), CACHE_TTL_MS);
|
|
236
421
|
}
|
|
237
422
|
await syncFooterInstall();
|
|
238
423
|
publishSnapshot();
|
|
@@ -241,9 +426,12 @@ async function refresh(force: boolean): Promise<QuotaSnapshot | undefined> {
|
|
|
241
426
|
}
|
|
242
427
|
// A model switch that arrived while this refresh was in flight left its
|
|
243
428
|
// own sync() call a no-op behind the refreshing guard; catch up for the
|
|
244
|
-
// new provider immediately instead of waiting for the next
|
|
245
|
-
if (activeProvider && activeProvider.kind !== provider.kind) void refresh(
|
|
246
|
-
|
|
429
|
+
// new provider immediately instead of waiting for the next activity.
|
|
430
|
+
if (activeProvider && activeProvider.kind !== provider.kind) void refresh().catch(() => {});
|
|
431
|
+
// Report what this call produced: the fetched snapshot even when a
|
|
432
|
+
// mid-fetch model switch kept it out of the footer (describeSnapshot
|
|
433
|
+
// names its kind), else the active provider's latest from memory.
|
|
434
|
+
return { snapshot: fetchedSnapshot ?? currentSnapshot, fetched, limited, error: fetchError };
|
|
247
435
|
}
|
|
248
436
|
|
|
249
437
|
function stopTimer(): void {
|
|
@@ -255,13 +443,11 @@ function stopTimer(): void {
|
|
|
255
443
|
|
|
256
444
|
function startTimer(): void {
|
|
257
445
|
if (tickTimer !== undefined) return;
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
void refresh(false).catch(() => {});
|
|
264
|
-
}, TICK_MS);
|
|
446
|
+
// Render-only tick: countdowns and reset times stay current once a minute
|
|
447
|
+
// without touching the network — fetches happen on activity (agent runs,
|
|
448
|
+
// model switches) and on demand (/quota), never on a wall clock. Unref'd
|
|
449
|
+
// so the timer never keeps pi alive.
|
|
450
|
+
tickTimer = setInterval(() => publishSnapshot(), TICK_MS);
|
|
265
451
|
tickTimer.unref?.();
|
|
266
452
|
}
|
|
267
453
|
|
|
@@ -340,15 +526,117 @@ async function sync(ctx: ExtensionContext, model: ActiveModel | undefined): Prom
|
|
|
340
526
|
return;
|
|
341
527
|
}
|
|
342
528
|
startTimer();
|
|
343
|
-
void refresh(
|
|
529
|
+
void refresh().catch(() => {});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Streaming activity (agent run started or finished): the active
|
|
534
|
+
* subscription's numbers may have moved with the work. Refresh in the
|
|
535
|
+
* background, TTL- and rate-gated — at most one request per CACHE_TTL_MS
|
|
536
|
+
* while the session is active, none while it sits idle or displays no
|
|
537
|
+
* footer (mode off, non-TUI). Never awaited: the agent loop must not
|
|
538
|
+
* wait on a status fetch.
|
|
539
|
+
*/
|
|
540
|
+
function onAgentActivity(): void {
|
|
541
|
+
// Off and non-TUI sessions show nothing (the /quota panel fetches on
|
|
542
|
+
// demand), so a background refresh there would be cost without a viewer.
|
|
543
|
+
if (currentMode === "off" || (activeCtx !== undefined && activeCtx.mode !== "tui")) return;
|
|
544
|
+
void refresh().catch(() => {});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Short fetch-failure text for the panel; the endpoint is implied by the provider block it belongs to. */
|
|
548
|
+
function panelFetchError(message: string): string {
|
|
549
|
+
return message.replace(/ from \S+.*$/, "");
|
|
344
550
|
}
|
|
345
551
|
|
|
346
|
-
/**
|
|
347
|
-
|
|
552
|
+
/**
|
|
553
|
+
* One configured provider as a panel row, rendered from cache alone: fresh
|
|
554
|
+
* snapshots as-is, stale ones with their numbers kept and marked `~`,
|
|
555
|
+
* missing ones as a note — "fetching…" while a refresh is in flight, the
|
|
556
|
+
* failure reason or retry countdown after it failed. A remembered failure
|
|
557
|
+
* rides along with the numbers it explains, so stale data always says why.
|
|
558
|
+
*/
|
|
559
|
+
function panelRow(
|
|
560
|
+
entry: ConfiguredQuotaProvider,
|
|
561
|
+
authFile: Record<string, unknown> | undefined,
|
|
562
|
+
cached: QuotaSnapshot | undefined,
|
|
563
|
+
opts: { pending: boolean; fetchError?: string; retryInSeconds?: number },
|
|
564
|
+
now: number,
|
|
565
|
+
): QuotaPanelRow {
|
|
566
|
+
const row: QuotaPanelRow = {
|
|
567
|
+
label: entry.providerId,
|
|
568
|
+
planLabel: entry.provider.planLabel,
|
|
569
|
+
active: entry.provider.kind === activeProvider?.kind,
|
|
570
|
+
};
|
|
571
|
+
if (cached) {
|
|
572
|
+
// Any age: show the numbers now — old ones marked stale (~), replaced
|
|
573
|
+
// by the pending refresh when it lands. freshSnapshot is a type guard,
|
|
574
|
+
// so the age check is inlined to keep `cached` narrowed here.
|
|
575
|
+
const fresh = now - cached.fetchedAt < CACHE_TTL_MS;
|
|
576
|
+
const failure = opts.fetchError ? `quota unavailable (${panelFetchError(opts.fetchError)})` : undefined;
|
|
577
|
+
return fresh
|
|
578
|
+
? { ...row, planLevel: cached.planLevel, snapshot: cached, note: failure }
|
|
579
|
+
: { ...row, planLevel: cached.planLevel, snapshot: cached, stale: true, note: failure };
|
|
580
|
+
}
|
|
581
|
+
if (opts.pending) return { ...row, note: "fetching…" };
|
|
582
|
+
if (opts.fetchError) return { ...row, note: `quota unavailable (${panelFetchError(opts.fetchError)})` };
|
|
583
|
+
if (oauthExpired(authFile?.[entry.providerId])) {
|
|
584
|
+
return { ...row, note: "oauth token expired; it refreshes the next time pi uses this provider" };
|
|
585
|
+
}
|
|
586
|
+
if (opts.retryInSeconds !== undefined) return { ...row, note: `quota unavailable (retry in ${opts.retryInSeconds}s)` };
|
|
587
|
+
return row;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The /quota panel text for one cache state; pending and errors describe an in-flight refresh. */
|
|
591
|
+
function renderPanel(
|
|
592
|
+
ordered: readonly ConfiguredQuotaProvider[],
|
|
593
|
+
authFile: Record<string, unknown> | undefined,
|
|
594
|
+
cache: QuotaCacheFile,
|
|
595
|
+
pending: ReadonlySet<ProviderKind>,
|
|
596
|
+
errors: ReadonlyMap<ProviderKind, string>,
|
|
597
|
+
): string {
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
const rows = ordered.map((entry): QuotaPanelRow => {
|
|
600
|
+
const kind = entry.provider.kind;
|
|
601
|
+
const lastRequestAt = cache.lastRequests?.[kind];
|
|
602
|
+
// A credential-less provider never fetches, so a recent attempt only
|
|
603
|
+
// matters as a retry note when a request could follow.
|
|
604
|
+
const retryInSeconds =
|
|
605
|
+
pending.has(kind) || !hasUsableCredential(authFile?.[entry.providerId])
|
|
606
|
+
? undefined
|
|
607
|
+
: lastRequestAt !== undefined && !requestDue(lastRequestAt, now, MIN_REQUEST_INTERVAL_MS)
|
|
608
|
+
? Math.ceil((lastRequestAt + MIN_REQUEST_INTERVAL_MS - now) / 1000)
|
|
609
|
+
: undefined;
|
|
610
|
+
return panelRow(
|
|
611
|
+
entry,
|
|
612
|
+
authFile,
|
|
613
|
+
cache.snapshots[kind],
|
|
614
|
+
{
|
|
615
|
+
pending: pending.has(kind),
|
|
616
|
+
fetchError: errors.get(kind),
|
|
617
|
+
retryInSeconds,
|
|
618
|
+
},
|
|
619
|
+
now,
|
|
620
|
+
);
|
|
621
|
+
});
|
|
622
|
+
return `${formatQuotaPanel(rows, now)}\n\n/quota refresh · /quota mode line|inline|off · /quota help`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Show the /quota panel without waiting on the network: the cached state of
|
|
627
|
+
* every configured provider renders immediately (stale snapshots keep their
|
|
628
|
+
* numbers behind `~`, missing ones read "fetching…"), then the stale or
|
|
629
|
+
* missing providers refresh in the background and the panel re-renders
|
|
630
|
+
* once — unless nothing changed (every fetch failed with only stale
|
|
631
|
+
* numbers shown, which the first panel already says with its `~` marks).
|
|
632
|
+
*/
|
|
633
|
+
async function showQuotaPanel(ctx: ExtensionContext): Promise<void> {
|
|
348
634
|
const authFile = await readAuthFile();
|
|
349
635
|
const configured = configuredQuotaProviders(authFile);
|
|
350
|
-
if (configured.length === 0)
|
|
351
|
-
|
|
636
|
+
if (configured.length === 0) {
|
|
637
|
+
ctx.ui.notify("no quota providers configured", "info");
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
352
640
|
// Active provider first, the rest alphabetical by display label —
|
|
353
641
|
// predictable and stable, independent of auth.json key order.
|
|
354
642
|
const ordered = [...configured].sort((a, b) => {
|
|
@@ -356,47 +644,67 @@ async function describeAllProviders(): Promise<string> {
|
|
|
356
644
|
if (b.provider.kind === activeProvider?.kind) return 1;
|
|
357
645
|
return a.providerId < b.providerId ? -1 : a.providerId > b.providerId ? 1 : 0;
|
|
358
646
|
});
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
647
|
+
const cache = await readCacheFile();
|
|
648
|
+
const now = Date.now();
|
|
649
|
+
// A remembered failure explains missing-or-stale numbers for its provider
|
|
650
|
+
// until fresher data lands; fetch errors belong on this panel, nowhere else.
|
|
651
|
+
const rememberedErrors = (snapshots: QuotaCacheFile["snapshots"]): Map<ProviderKind, string> => {
|
|
652
|
+
const remembered = new Map<ProviderKind, string>();
|
|
653
|
+
for (const entry of ordered) {
|
|
654
|
+
const failure = lastErrors.get(entry.provider.kind);
|
|
655
|
+
if (!failure) continue;
|
|
656
|
+
const snapshot = snapshots[entry.provider.kind];
|
|
657
|
+
if (!snapshot || snapshot.fetchedAt < failure.at) remembered.set(entry.provider.kind, failure.message);
|
|
658
|
+
}
|
|
659
|
+
return remembered;
|
|
660
|
+
};
|
|
661
|
+
// Providers needing a fetch: a stale-or-missing snapshot, a usable
|
|
662
|
+
// credential, and the machine-wide request interval elapsed. The rate
|
|
663
|
+
// limit itself lives in fetchAndCache; this only keeps the first render
|
|
664
|
+
// honest about what is about to happen.
|
|
665
|
+
const due = ordered.filter(
|
|
666
|
+
(entry) =>
|
|
667
|
+
!freshSnapshot(cache.snapshots[entry.provider.kind], now, CACHE_TTL_MS) &&
|
|
668
|
+
hasUsableCredential(authFile?.[entry.providerId]) &&
|
|
669
|
+
requestDue(cache.lastRequests?.[entry.provider.kind], now, MIN_REQUEST_INTERVAL_MS),
|
|
670
|
+
);
|
|
671
|
+
const first = renderPanel(
|
|
672
|
+
ordered,
|
|
673
|
+
authFile,
|
|
674
|
+
cache,
|
|
675
|
+
new Set(due.map((entry) => entry.provider.kind)),
|
|
676
|
+
rememberedErrors(cache.snapshots),
|
|
677
|
+
);
|
|
678
|
+
ctx.ui.notify(first, "info");
|
|
679
|
+
if (due.length === 0) return;
|
|
680
|
+
const errors = new Map<ProviderKind, string>();
|
|
681
|
+
await Promise.allSettled(
|
|
682
|
+
due.map(async (entry) => {
|
|
683
|
+
// The active provider keeps its regional base URL; the rest use
|
|
684
|
+
// their default endpoint.
|
|
685
|
+
const baseUrl = entry.provider.kind === activeProvider?.kind ? activeBaseUrl : undefined;
|
|
686
|
+
const outcome = await fetchAndCache(entry.provider, entry.providerId, baseUrl);
|
|
687
|
+
if (outcome.status === "error") errors.set(entry.provider.kind, outcome.message);
|
|
397
688
|
}),
|
|
398
689
|
);
|
|
399
|
-
|
|
690
|
+
const refreshed = await readCacheFile();
|
|
691
|
+
// This pass's own errors win; remembered ones fill in every other provider.
|
|
692
|
+
for (const [kind, message] of rememberedErrors(refreshed.snapshots)) {
|
|
693
|
+
if (!errors.has(kind)) errors.set(kind, message);
|
|
694
|
+
}
|
|
695
|
+
// A fresh snapshot for the active provider also updates the footer —
|
|
696
|
+
// unless an activity refresh is mid-flight, which owns that state.
|
|
697
|
+
if (!refreshing && activeProvider) {
|
|
698
|
+
const activeCached = refreshed.snapshots[activeProvider.kind];
|
|
699
|
+
if (activeCached && freshSnapshot(activeCached, Date.now(), CACHE_TTL_MS)) {
|
|
700
|
+
currentSnapshot = activeCached;
|
|
701
|
+
stale = false;
|
|
702
|
+
await syncFooterInstall();
|
|
703
|
+
publishSnapshot();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const second = renderPanel(ordered, authFile, refreshed, new Set(), errors);
|
|
707
|
+
if (second !== first) ctx.ui.notify(second, "info");
|
|
400
708
|
}
|
|
401
709
|
|
|
402
710
|
function describeSnapshot(snapshot: QuotaSnapshot | undefined): string {
|
|
@@ -408,7 +716,7 @@ function describeSnapshot(snapshot: QuotaSnapshot | undefined): string {
|
|
|
408
716
|
|
|
409
717
|
/** Sub-command hints for /quota autocompletion. */
|
|
410
718
|
const SUBCOMMANDS: { value: string; label: string; description: string }[] = [
|
|
411
|
-
{ value: "refresh", label: "refresh", description: "force a re-fetch for the active provider" },
|
|
719
|
+
{ value: "refresh", label: "refresh", description: "force a re-fetch for the active provider (once per minute at most)" },
|
|
412
720
|
{ value: "mode", label: "mode", description: "switch display mode (line, inline, off)" },
|
|
413
721
|
{ value: "help", label: "help", description: "show usage" },
|
|
414
722
|
];
|
|
@@ -432,6 +740,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
432
740
|
pi.on("model_select", async (event, ctx) => {
|
|
433
741
|
await sync(ctx, event.model);
|
|
434
742
|
});
|
|
743
|
+
// Run boundaries are the activity signal: refreshes ride on real work
|
|
744
|
+
// instead of a wall clock, so an idle session sends no requests at all.
|
|
745
|
+
pi.on("agent_start", onAgentActivity);
|
|
746
|
+
pi.on("agent_end", onAgentActivity);
|
|
435
747
|
pi.on("session_shutdown", () => {
|
|
436
748
|
// The runtime is about to be torn down (quit, /reload, session
|
|
437
749
|
// replacement): stop the tick timer so a reused module instance never
|
|
@@ -460,14 +772,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
460
772
|
handler: async (args, ctx) => {
|
|
461
773
|
const [sub, arg] = args.trim().toLowerCase().split(/\s+/);
|
|
462
774
|
if (sub === "refresh") {
|
|
463
|
-
|
|
464
|
-
|
|
775
|
+
// Cycles are serialized, so a manual refresh waits for any
|
|
776
|
+
// in-flight one and then runs — and reports the rate limit — itself.
|
|
777
|
+
const result = await refresh({ force: true });
|
|
778
|
+
// A failed fetch is not worth interrupting anyone: the /quota panel
|
|
779
|
+
// explains the outage on its own surface, with the numbers it has.
|
|
780
|
+
if (result.error !== undefined) {
|
|
781
|
+
await showQuotaPanel(ctx);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
// Without a credential there is nothing to fetch; and without data
|
|
785
|
+
// or a rate-limit skip, nothing worth reporting either.
|
|
786
|
+
if (!result.fetched && !result.limited) {
|
|
787
|
+
ctx.ui.notify("Quota: nothing to refresh", "warning");
|
|
465
788
|
return;
|
|
466
789
|
}
|
|
467
|
-
const snapshot = await refresh(true);
|
|
468
790
|
ctx.ui.notify(
|
|
469
|
-
|
|
470
|
-
|
|
791
|
+
result.fetched
|
|
792
|
+
? `Quota refreshed: ${describeSnapshot(result.snapshot)}`
|
|
793
|
+
: `Quota: requested within the last minute — ${describeSnapshot(result.snapshot)}`,
|
|
794
|
+
"info",
|
|
471
795
|
);
|
|
472
796
|
return;
|
|
473
797
|
}
|
|
@@ -495,8 +819,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
495
819
|
);
|
|
496
820
|
return;
|
|
497
821
|
}
|
|
498
|
-
// No argument (or anything unrecognized):
|
|
499
|
-
|
|
822
|
+
// No argument (or anything unrecognized): cached numbers instantly,
|
|
823
|
+
// refreshed in the background and re-rendered when they land.
|
|
824
|
+
await showQuotaPanel(ctx);
|
|
500
825
|
},
|
|
501
826
|
});
|
|
502
827
|
}
|
package/package.json
CHANGED
package/view.ts
CHANGED
|
@@ -316,11 +316,7 @@ export function formatQuotaPanel(rows: QuotaPanelRow[], now: number): string {
|
|
|
316
316
|
.filter(Boolean)
|
|
317
317
|
.join(" · ");
|
|
318
318
|
const lines = [`${row.stale ? "~" : ""}${row.label} ${header}`];
|
|
319
|
-
if (
|
|
320
|
-
lines.push(` ${row.note}`);
|
|
321
|
-
} else if (!snapshot) {
|
|
322
|
-
lines.push(" quota unavailable");
|
|
323
|
-
} else {
|
|
319
|
+
if (snapshot) {
|
|
324
320
|
for (const window of windowItems(snapshot)) {
|
|
325
321
|
const left = 100 - window.usedPercent;
|
|
326
322
|
const reset =
|
|
@@ -334,6 +330,13 @@ export function formatQuotaPanel(rows: QuotaPanelRow[], now: number): string {
|
|
|
334
330
|
}
|
|
335
331
|
}
|
|
336
332
|
}
|
|
333
|
+
// A note rides along below the numbers it explains; only a row with
|
|
334
|
+
// neither snapshot nor note falls back to the bare unavailability line.
|
|
335
|
+
if (row.note) {
|
|
336
|
+
lines.push(` ${row.note}`);
|
|
337
|
+
} else if (!snapshot) {
|
|
338
|
+
lines.push(" quota unavailable");
|
|
339
|
+
}
|
|
337
340
|
return lines.join("\n");
|
|
338
341
|
})
|
|
339
342
|
.join("\n\n");
|