pi-opencode-go-provider 1.0.24 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Lifecycle for the OpenCode Go usage widget.
3
+ *
4
+ * Mirrors the pi-better-openai usage controller: one snapshot per session,
5
+ * polled on a timer plus after each turn, pushed into the extension widget
6
+ * (favoured) or the footer status line when no terminal UI is available.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { PROVIDER_ID, configPath, type UsageConfig } from "./config.ts";
11
+ import { getActiveMultiproviderService } from "./multiprovider.ts";
12
+ import {
13
+ USAGE_LIMITS_NOTE,
14
+ USAGE_URL,
15
+ formatUsageDetail,
16
+ formatUsageLine,
17
+ parseUsageSnapshot,
18
+ usageHttpErrorMessage,
19
+ type UsageSnapshot,
20
+ } from "./usage.ts";
21
+
22
+ const REQUEST_TIMEOUT_MS = 10_000;
23
+ const STALE_CONTEXT_MESSAGE = "This extension ctx is stale";
24
+ const MISSING_KEY_MESSAGE =
25
+ "no opencode-go API key. Set OPENCODE_API_KEY or add \"opencode-go\" to ~/.pi/agent/auth.json.";
26
+
27
+ type UsageRefreshOptions = { notify?: boolean; force?: boolean };
28
+
29
+ type QueuedRefresh = {
30
+ ctx: ExtensionContext;
31
+ generation: number;
32
+ notify?: boolean;
33
+ force?: boolean;
34
+ };
35
+
36
+ function isStaleContextError(error: unknown): boolean {
37
+ return error instanceof Error && error.message.includes(STALE_CONTEXT_MESSAGE);
38
+ }
39
+
40
+ export class UsageController {
41
+ private usageSnapshot: UsageSnapshot | undefined;
42
+ private usageUpdatedAt: number | undefined;
43
+ private usageError: string | undefined;
44
+ private usageLastFetchAt: number | undefined;
45
+ private usageTimer: ReturnType<typeof setInterval> | undefined;
46
+ private usageRefreshInFlight = false;
47
+ private queuedRefresh: QueuedRefresh | undefined;
48
+ private shuttingDown = false;
49
+ private usageAbortController: AbortController | undefined;
50
+ private generation = 0;
51
+ private readonly getConfig: () => UsageConfig;
52
+ private readonly onUpdate: (ctx: ExtensionContext) => void;
53
+
54
+ constructor(getConfig: () => UsageConfig, onUpdate: (ctx: ExtensionContext) => void) {
55
+ this.getConfig = getConfig;
56
+ this.onUpdate = onUpdate;
57
+ }
58
+
59
+ get snapshot(): UsageSnapshot | undefined {
60
+ return this.usageSnapshot;
61
+ }
62
+
63
+ get lastError(): string | undefined {
64
+ return this.usageError;
65
+ }
66
+
67
+ /** True when the last poll failed but an older snapshot is still on screen. */
68
+ isStale(): boolean {
69
+ return this.usageError !== undefined && this.usageSnapshot !== undefined;
70
+ }
71
+
72
+ isEligible(ctx: ExtensionContext): boolean {
73
+ try {
74
+ return !this.getConfig().showOnlyOnProvider || ctx.model?.provider === PROVIDER_ID;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /** One-line widget/status text, or undefined when nothing should be shown. */
81
+ line(ctx: ExtensionContext, config = this.getConfig()): string | undefined {
82
+ if (!config.enabled || !this.usageSnapshot || !this.isEligible(ctx)) return undefined;
83
+ const line = formatUsageLine(this.usageSnapshot, { showResetTimes: config.showResetTimes });
84
+ return this.isStale() ? `${line} · stale` : line;
85
+ }
86
+
87
+ formatStatus(ctx: ExtensionContext): string {
88
+ const config = this.getConfig();
89
+ if (!config.enabled) {
90
+ return "OpenCode Go usage display is disabled. Re-enable it with /opencode-go-usage on.";
91
+ }
92
+ if (!this.isEligible(ctx)) {
93
+ return "OpenCode Go usage is hidden: the selected model is not from the opencode-go provider.";
94
+ }
95
+ if (this.usageError) return `OpenCode Go usage unavailable: ${this.usageError}`;
96
+ if (!this.usageSnapshot) return "OpenCode Go usage unavailable: nothing fetched yet.";
97
+ const lines = [
98
+ `OpenCode Go usage · ${USAGE_LIMITS_NOTE}`,
99
+ ...formatUsageDetail(this.usageSnapshot),
100
+ ];
101
+ if (this.usageSnapshot.isLimited) {
102
+ lines.push("A window is rate limited; requests may be rejected until it resets.");
103
+ }
104
+ if (this.usageUpdatedAt !== undefined) {
105
+ lines.push(`Updated ${new Date(this.usageUpdatedAt).toLocaleTimeString()}`);
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+
110
+ formatDebug(ctx: ExtensionContext): string {
111
+ const config = this.getConfig();
112
+ let provider = "unknown";
113
+ try {
114
+ provider = ctx.model?.provider ?? "none";
115
+ } catch {
116
+ provider = "stale ctx";
117
+ }
118
+ return [
119
+ `Usage enabled: ${config.enabled}`,
120
+ `Usage placement: ${config.placement}`,
121
+ `Refresh interval: ${config.refreshIntervalMs}ms`,
122
+ `Show only on opencode-go models: ${config.showOnlyOnProvider}`,
123
+ `Show reset times: ${config.showResetTimes}`,
124
+ `Current model provider: ${provider}`,
125
+ `Last fetch: ${this.usageLastFetchAt ? new Date(this.usageLastFetchAt).toLocaleTimeString() : "never"}`,
126
+ `Last success: ${this.usageUpdatedAt ? new Date(this.usageUpdatedAt).toLocaleTimeString() : "never"}`,
127
+ `Last error: ${this.usageError ?? "none"}`,
128
+ `Windows: ${this.usageSnapshot?.windows.map((window) => `${window.label}=${window.remainingPercent}% left`).join(" ") ?? "none"}`,
129
+ `Endpoint: ${USAGE_URL}`,
130
+ `Config file: ${configPath()}`,
131
+ ].join("\n");
132
+ }
133
+
134
+ private isCurrent(generation: number): boolean {
135
+ return !this.shuttingDown && generation === this.generation;
136
+ }
137
+
138
+ private deactivate(generation: number): void {
139
+ if (generation !== this.generation) return;
140
+ this.shuttingDown = true;
141
+ this.generation++;
142
+ this.queuedRefresh = undefined;
143
+ this.usageAbortController?.abort();
144
+ this.usageAbortController = undefined;
145
+ this.stopTimer();
146
+ }
147
+
148
+ private notify(
149
+ ctx: ExtensionContext,
150
+ message: string,
151
+ level: "info" | "warning" | "error",
152
+ ): void {
153
+ try {
154
+ ctx.ui.notify(message, level);
155
+ } catch {
156
+ // Notifications are best-effort: a stale context must not surface as an error.
157
+ }
158
+ }
159
+
160
+ /**
161
+ * The session's active pooled account, when pi-multiprovider pools
162
+ * opencode-go. Every account carries its own budget, so a switched or
163
+ * restored account must bill itself instead of Pi's default credential.
164
+ */
165
+ private async resolvePooledApiKey(ctx: ExtensionContext): Promise<string | undefined> {
166
+ const service = getActiveMultiproviderService();
167
+ if (service === undefined) return undefined;
168
+ try {
169
+ const resolved = await service.resolveActiveAccountAuth(PROVIDER_ID, ctx);
170
+ const token = resolved?.accessToken.trim();
171
+ return token ? token : undefined;
172
+ } catch {
173
+ // A failing bridge must never block the default resolution below.
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ private async resolveApiKey(ctx: ExtensionContext): Promise<string | undefined> {
179
+ const pooled = await this.resolvePooledApiKey(ctx);
180
+ if (pooled !== undefined) return pooled;
181
+ let key: string | undefined;
182
+ try {
183
+ key = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER_ID);
184
+ } catch {
185
+ key = undefined;
186
+ }
187
+ if (key?.trim()) return key.trim();
188
+ const envKey = process.env.OPENCODE_API_KEY?.trim();
189
+ return envKey ? envKey : undefined;
190
+ }
191
+
192
+ private fail(ctx: ExtensionContext, message: string, options?: UsageRefreshOptions): void {
193
+ this.usageError = message;
194
+ this.onUpdate(ctx);
195
+ if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "warning");
196
+ }
197
+
198
+ async refresh(
199
+ ctx: ExtensionContext,
200
+ options?: UsageRefreshOptions,
201
+ generation = this.generation,
202
+ ): Promise<void> {
203
+ if (!this.isCurrent(generation)) return;
204
+
205
+ const config = this.getConfig();
206
+ if (!config.enabled) {
207
+ this.usageSnapshot = undefined;
208
+ this.usageUpdatedAt = undefined;
209
+ this.usageError = "usage display is disabled.";
210
+ this.onUpdate(ctx);
211
+ if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "warning");
212
+ return;
213
+ }
214
+
215
+ let eligible: boolean;
216
+ try {
217
+ eligible = this.isEligible(ctx);
218
+ } catch (error) {
219
+ if (isStaleContextError(error)) this.deactivate(generation);
220
+ return;
221
+ }
222
+ if (!eligible) {
223
+ // Keep the snapshot cached so switching back to opencode-go is instant.
224
+ this.onUpdate(ctx);
225
+ if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "warning");
226
+ return;
227
+ }
228
+
229
+ if (this.usageRefreshInFlight) {
230
+ const queued = this.queuedRefresh?.generation === generation ? this.queuedRefresh : undefined;
231
+ this.queuedRefresh = {
232
+ ctx,
233
+ generation,
234
+ notify: queued?.notify || options?.notify,
235
+ force: queued?.force || options?.force,
236
+ };
237
+ return;
238
+ }
239
+
240
+ const shouldThrottle =
241
+ !options?.force &&
242
+ !options?.notify &&
243
+ this.usageLastFetchAt !== undefined &&
244
+ Date.now() - this.usageLastFetchAt < config.refreshIntervalMs;
245
+ if (shouldThrottle) {
246
+ this.onUpdate(ctx);
247
+ return;
248
+ }
249
+
250
+ this.usageRefreshInFlight = true;
251
+ try {
252
+ const apiKey = await this.resolveApiKey(ctx);
253
+ if (!this.isCurrent(generation)) return;
254
+ if (!apiKey) {
255
+ this.usageSnapshot = undefined;
256
+ this.usageUpdatedAt = undefined;
257
+ this.fail(ctx, MISSING_KEY_MESSAGE, options);
258
+ return;
259
+ }
260
+
261
+ this.usageLastFetchAt = Date.now();
262
+ this.usageAbortController = new AbortController();
263
+ const response = await fetch(USAGE_URL, {
264
+ headers: { accept: "application/json", authorization: `Bearer ${apiKey}` },
265
+ signal: AbortSignal.any([
266
+ AbortSignal.timeout(REQUEST_TIMEOUT_MS),
267
+ this.usageAbortController.signal,
268
+ ]),
269
+ });
270
+ if (!this.isCurrent(generation)) return;
271
+ if (!response.ok) {
272
+ let body: unknown;
273
+ try {
274
+ body = await response.json();
275
+ } catch {
276
+ body = undefined;
277
+ }
278
+ throw new Error(usageHttpErrorMessage(response.status, body));
279
+ }
280
+
281
+ const payload: unknown = await response.json();
282
+ if (!this.isCurrent(generation)) return;
283
+ const snapshot = parseUsageSnapshot(payload);
284
+ if (!snapshot) throw new Error("unrecognized usage payload from OpenCode Go.");
285
+
286
+ this.usageSnapshot = snapshot;
287
+ this.usageUpdatedAt = Date.now();
288
+ this.usageError = undefined;
289
+ this.onUpdate(ctx);
290
+ if (options?.notify) this.notify(ctx, this.formatStatus(ctx), "info");
291
+ } catch (error) {
292
+ if (!this.isCurrent(generation)) return;
293
+ if (isStaleContextError(error)) {
294
+ this.deactivate(generation);
295
+ return;
296
+ }
297
+ const message = error instanceof Error ? error.message : String(error);
298
+ this.fail(ctx, message, options);
299
+ } finally {
300
+ this.usageAbortController = undefined;
301
+ this.usageRefreshInFlight = false;
302
+ const next = this.queuedRefresh;
303
+ this.queuedRefresh = undefined;
304
+ if (next && !this.shuttingDown && next.generation === this.generation) {
305
+ void this.refresh(next.ctx, { notify: next.notify, force: next.force }, next.generation);
306
+ }
307
+ }
308
+ }
309
+
310
+ private stopTimer(): void {
311
+ if (this.usageTimer) clearInterval(this.usageTimer);
312
+ this.usageTimer = undefined;
313
+ }
314
+
315
+ start(ctx: ExtensionContext): void {
316
+ this.usageAbortController?.abort();
317
+ this.queuedRefresh = undefined;
318
+ this.stopTimer();
319
+ const generation = ++this.generation;
320
+ this.shuttingDown = false;
321
+
322
+ if (!this.getConfig().enabled) return;
323
+ void this.refresh(ctx, { force: true }, generation);
324
+ this.usageTimer = setInterval(() => {
325
+ if (!this.isCurrent(generation)) return;
326
+ void this.refresh(ctx, undefined, generation);
327
+ }, this.getConfig().refreshIntervalMs);
328
+ this.usageTimer.unref?.();
329
+ }
330
+
331
+ /** Stop polling and drop the cached snapshot (used when the widget is turned off). */
332
+ stop(): void {
333
+ this.usageAbortController?.abort();
334
+ this.usageAbortController = undefined;
335
+ this.queuedRefresh = undefined;
336
+ this.stopTimer();
337
+ }
338
+
339
+ clear(): void {
340
+ this.usageSnapshot = undefined;
341
+ this.usageUpdatedAt = undefined;
342
+ this.usageError = undefined;
343
+ this.usageLastFetchAt = undefined;
344
+ }
345
+
346
+ shutdown(): void {
347
+ this.deactivate(this.generation);
348
+ }
349
+ }
package/usage.ts ADDED
@@ -0,0 +1,341 @@
1
+ /**
2
+ * OpenCode Go usage windows.
3
+ *
4
+ * `GET https://opencode.ai/zen/go/v1/usage` is the only place the Go plan's
5
+ * three meter windows are published. Server source:
6
+ * sst/opencode packages/console/app/src/routes/zen/go/v1/usage.ts
7
+ * sst/opencode packages/console/core/src/subscription.ts
8
+ *
9
+ * Authorization: Bearer <api-key>
10
+ * 200 { "usage": { "rolling" | "weekly" | "monthly":
11
+ * { "status": "ok" | "rate-limited", "percent": 0-100,
12
+ * "resetsAt": "<ISO-8601>" } } }
13
+ * 401 AuthError (missing/unknown key), 403 EntitlementError (no Go plan)
14
+ *
15
+ * `percent` is the share of the window budget already spent
16
+ * (`floor(min(100, usage / limit * 100))`, forced to 100 once the window is rate
17
+ * limited), so every surface here reports `100 - percent` — the budget you have
18
+ * left. The dollar limits are not on the wire; see USAGE_LIMITS_NOTE.
19
+ *
20
+ * The widget line mirrors the pi-better-openai usage line so both providers read
21
+ * the same way:
22
+ *
23
+ * Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h ·
24
+ * 30d ↺ 20d0h
25
+ */
26
+
27
+ export const USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
28
+
29
+ /** Published Go budget per window; informational only, not sent by the API. */
30
+ export const USAGE_LIMITS_NOTE = "5h $12 · 7d $30 · 30d $60";
31
+
32
+ export type UsageWindowKey = "rolling" | "weekly" | "monthly";
33
+ export type UsageStatus = "ok" | "rate-limited" | "unknown";
34
+ export type UsageSeverity = "ok" | "warning" | "critical" | "muted";
35
+
36
+ export interface UsageWindow {
37
+ key: UsageWindowKey;
38
+ /** Short human label: 5h / 7d / 30d. */
39
+ label: string;
40
+ status: UsageStatus;
41
+ /** 0-100, share of the window budget already consumed (raw API value). */
42
+ usedPercent: number;
43
+ /** 0-100, share of the window budget still available (100 - usedPercent). */
44
+ remainingPercent: number;
45
+ /** Window rollover instant in epoch milliseconds, or null when unknown. */
46
+ resetsAt: number | null;
47
+ }
48
+
49
+ export interface UsageSnapshot {
50
+ capturedAt: number;
51
+ windows: UsageWindow[];
52
+ isLimited: boolean;
53
+ /**
54
+ * Banked reset credits. The Go API does not expose this today — its payload
55
+ * carries only status/percent/resetsAt per window — so the segment stays off
56
+ * the line until a response actually includes `bankedResets`.
57
+ */
58
+ bankedResets: number | null;
59
+ }
60
+
61
+ export interface UsageSegment {
62
+ text: string;
63
+ severity: UsageSeverity;
64
+ }
65
+
66
+ export interface UsageFormatOptions {
67
+ showResetTimes: boolean;
68
+ showBankedResets?: boolean;
69
+ }
70
+
71
+ export const USAGE_WINDOW_LABELS: Record<UsageWindowKey, string> = {
72
+ rolling: "5h",
73
+ weekly: "7d",
74
+ monthly: "30d",
75
+ };
76
+
77
+ /** Whether a window's reset needs a calendar date as well as a weekday. */
78
+ const USAGE_WINDOW_INCLUDE_DATE: Record<UsageWindowKey, boolean> = {
79
+ rolling: false,
80
+ weekly: true,
81
+ monthly: true,
82
+ };
83
+
84
+ const WINDOW_KEYS: UsageWindowKey[] = ["rolling", "weekly", "monthly"];
85
+ /** Remaining budget at or below these thresholds turns the percentage amber/red. */
86
+ const WARNING_REMAINING_PERCENT = 30;
87
+ const CRITICAL_REMAINING_PERCENT = 10;
88
+
89
+ function isRecord(value: unknown): value is Record<string, unknown> {
90
+ return typeof value === "object" && value !== null && !Array.isArray(value);
91
+ }
92
+
93
+ export function clampPercent(value: number): number {
94
+ return Math.min(100, Math.max(0, value));
95
+ }
96
+
97
+ function toFiniteNumber(value: unknown): number | undefined {
98
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
99
+ if (typeof value === "string" && value.trim()) {
100
+ const parsed = Number(value.trim());
101
+ return Number.isFinite(parsed) ? parsed : undefined;
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ /** Accept ISO-8601 strings, epoch seconds, or epoch milliseconds. */
107
+ export function parseResetAt(value: unknown): number | null {
108
+ if (typeof value === "number" && Number.isFinite(value)) {
109
+ return value < 1e12 ? value * 1000 : value;
110
+ }
111
+ if (typeof value === "string" && value.trim()) {
112
+ const trimmed = value.trim();
113
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return parseResetAt(Number(trimmed));
114
+ const parsed = Date.parse(trimmed);
115
+ return Number.isFinite(parsed) ? parsed : null;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function parseWindow(key: UsageWindowKey, raw: unknown): UsageWindow | undefined {
121
+ if (!isRecord(raw)) return undefined;
122
+ const percent = toFiniteNumber(raw.percent);
123
+ if (percent === undefined) return undefined;
124
+ const status: UsageStatus =
125
+ raw.status === "rate-limited" ? "rate-limited" : raw.status === "ok" ? "ok" : "unknown";
126
+ const usedPercent = clampPercent(percent);
127
+ return {
128
+ key,
129
+ label: USAGE_WINDOW_LABELS[key],
130
+ status,
131
+ usedPercent,
132
+ remainingPercent: 100 - usedPercent,
133
+ resetsAt: parseResetAt(raw.resetsAt),
134
+ };
135
+ }
136
+
137
+ function parseBankedResets(usage: Record<string, unknown>): number | null {
138
+ const count = toFiniteNumber(usage.bankedResets);
139
+ return count !== undefined && Number.isInteger(count) && count >= 0 ? count : null;
140
+ }
141
+
142
+ /**
143
+ * Parse a usage response into a snapshot. Returns undefined when the payload
144
+ * carries no usable window so callers can report "no data" instead of zeros.
145
+ */
146
+ export function parseUsageSnapshot(payload: unknown, now = Date.now()): UsageSnapshot | undefined {
147
+ if (!isRecord(payload) || !isRecord(payload.usage)) return undefined;
148
+ const usage = payload.usage;
149
+ const windows: UsageWindow[] = [];
150
+ for (const key of WINDOW_KEYS) {
151
+ const window = parseWindow(key, usage[key]);
152
+ if (window) windows.push(window);
153
+ }
154
+ if (windows.length === 0) return undefined;
155
+ return {
156
+ capturedAt: now,
157
+ windows,
158
+ isLimited: windows.some((window) => window.status === "rate-limited"),
159
+ bankedResets: parseBankedResets(usage),
160
+ };
161
+ }
162
+
163
+ /** Turn a non-2xx usage response into an actionable message. */
164
+ export function usageHttpErrorMessage(status: number, payload: unknown): string {
165
+ const detail =
166
+ isRecord(payload) && isRecord(payload.error) && typeof payload.error.message === "string"
167
+ ? payload.error.message
168
+ : undefined;
169
+ const suffix = detail ? `: ${detail}` : ".";
170
+ if (status === 401) return `OpenCode Go rejected the API key (401)${suffix}`;
171
+ if (status === 403) return `This key has no OpenCode Go subscription (403)${suffix}`;
172
+ return `OpenCode Go usage request failed (${status})${suffix}`;
173
+ }
174
+
175
+ export function formatPercent(value: number): string {
176
+ return `${Math.round(clampPercent(value))}%`;
177
+ }
178
+
179
+ /** Compact duration for a countdown: 2h14m / 5d3h / 45m / 12s / now. */
180
+ export function formatCountdown(milliseconds: number): string {
181
+ if (!Number.isFinite(milliseconds)) return "unknown";
182
+ const total = Math.max(0, Math.round(milliseconds / 1000));
183
+ if (total === 0) return "now";
184
+ const days = Math.floor(total / 86_400);
185
+ const hours = Math.floor((total % 86_400) / 3600);
186
+ const minutes = Math.floor((total % 3600) / 60);
187
+ const seconds = total % 60;
188
+ if (days > 0) return `${days}d${hours}h`;
189
+ if (hours > 0) return `${hours}h${minutes}m`;
190
+ if (minutes > 0) return `${minutes}m`;
191
+ return `${seconds}s`;
192
+ }
193
+
194
+ interface ResetClockFormatters {
195
+ time: Intl.DateTimeFormat;
196
+ weekday: Intl.DateTimeFormat;
197
+ date: Intl.DateTimeFormat;
198
+ }
199
+
200
+ const RESET_CLOCK_FORMATTER_LIMIT = 8;
201
+ const resetClockFormatters = new Map<string, ResetClockFormatters>();
202
+
203
+ function timeZoneId(): string {
204
+ try {
205
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "local";
206
+ } catch {
207
+ return "local";
208
+ }
209
+ }
210
+
211
+ /** Intl formatters are expensive; cache one set per time zone and offset. */
212
+ function resetClockFormatterFor(reset: Date): ResetClockFormatters {
213
+ const key = `${timeZoneId()}:${reset.getTimezoneOffset()}`;
214
+ let formatters = resetClockFormatters.get(key);
215
+ if (!formatters) {
216
+ formatters = {
217
+ time: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }),
218
+ weekday: new Intl.DateTimeFormat(undefined, { weekday: "short" }),
219
+ date: new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric" }),
220
+ };
221
+ resetClockFormatters.set(key, formatters);
222
+ while (resetClockFormatters.size > RESET_CLOCK_FORMATTER_LIMIT) {
223
+ const oldest = resetClockFormatters.keys().next().value;
224
+ if (oldest === undefined) break;
225
+ resetClockFormatters.delete(oldest);
226
+ }
227
+ }
228
+ return formatters;
229
+ }
230
+
231
+ /**
232
+ * Local wall-clock instant a window rolls over: "3:14 PM" later today,
233
+ * "Tue 3:14 PM" within the week, or "Tue 9/15 10:42 AM" with `includeDate`.
234
+ */
235
+ export function formatResetClock(
236
+ resetAt: number,
237
+ options?: { includeDate?: boolean },
238
+ now = Date.now(),
239
+ ): string | null {
240
+ const reset = new Date(resetAt);
241
+ if (Number.isNaN(reset.getTime())) return null;
242
+ const formatters = resetClockFormatterFor(reset);
243
+ const time = formatters.time.format(reset);
244
+ if (!options?.includeDate && reset.toDateString() === new Date(now).toDateString()) return time;
245
+ const weekday = formatters.weekday.format(reset);
246
+ if (!options?.includeDate) return `${weekday} ${time}`;
247
+ return `${weekday} ${formatters.date.format(reset)} ${time}`;
248
+ }
249
+
250
+ /**
251
+ * "↺ 2h14m", with the window label when several are listed. The widget carries
252
+ * countdowns only: three windows plus three wall-clock times run past the
253
+ * terminal width, so the exact reset time lives in the breakdown instead.
254
+ */
255
+ function formatCompactReset(
256
+ label: string | undefined,
257
+ resetAt: number | null,
258
+ now: number,
259
+ ): string | null {
260
+ if (resetAt === null) return null;
261
+ return `${label ? `${label} ` : ""}↺ ${formatCountdown(resetAt - now)}`;
262
+ }
263
+
264
+ /** "3 banked resets", or null when the count is absent or zero. */
265
+ export function formatBankedResetsSuffix(count: number | null): string | null {
266
+ if (count === null || !Number.isInteger(count) || count <= 0) return null;
267
+ return `${count} banked reset${count === 1 ? "" : "s"}`;
268
+ }
269
+
270
+ /** Remaining budget drives the colour: plenty left is green, nearly spent is red. */
271
+ export function severityForWindow(window: UsageWindow): UsageSeverity {
272
+ if (window.status === "rate-limited" || window.remainingPercent <= CRITICAL_REMAINING_PERCENT) {
273
+ return "critical";
274
+ }
275
+ if (window.remainingPercent <= WARNING_REMAINING_PERCENT) return "warning";
276
+ return "ok";
277
+ }
278
+
279
+ /**
280
+ * One-line widget, split into severity-tagged segments:
281
+ * "Usage: 5h: 63% · 7d: 41% · 30d: 12% · 5h ↺ 2h14m · 7d ↺ 3d20h · 30d ↺ …".
282
+ */
283
+ export function usageSegments(
284
+ snapshot: UsageSnapshot,
285
+ options: UsageFormatOptions,
286
+ now = Date.now(),
287
+ ): UsageSegment[] {
288
+ const windows = snapshot.windows;
289
+ const labelled = windows.length > 1;
290
+ const segments: UsageSegment[] = [{ text: "Usage: ", severity: "muted" }];
291
+ windows.forEach((window, index) => {
292
+ if (index > 0) segments.push({ text: " · ", severity: "muted" });
293
+ segments.push({ text: `${window.label}: `, severity: "muted" });
294
+ segments.push({
295
+ text: formatPercent(window.remainingPercent),
296
+ severity: severityForWindow(window),
297
+ });
298
+ });
299
+ if (options.showResetTimes) {
300
+ for (const window of windows) {
301
+ const reset = formatCompactReset(labelled ? window.label : undefined, window.resetsAt, now);
302
+ if (reset) segments.push({ text: ` · ${reset}`, severity: "muted" });
303
+ }
304
+ }
305
+ if (options.showBankedResets !== false) {
306
+ const banked = formatBankedResetsSuffix(snapshot.bankedResets);
307
+ if (banked) segments.push({ text: ` · ${banked}`, severity: "muted" });
308
+ }
309
+ return segments;
310
+ }
311
+
312
+ export function formatUsageLine(
313
+ snapshot: UsageSnapshot,
314
+ options: UsageFormatOptions,
315
+ now = Date.now(),
316
+ ): string {
317
+ return usageSegments(snapshot, options, now)
318
+ .map((segment) => segment.text)
319
+ .join("");
320
+ }
321
+
322
+ /** Progress bar: 20 cells, filled by the remaining percentage. */
323
+ export function formatBar(percent: number, width = 20): string {
324
+ const filled = Math.round((clampPercent(percent) / 100) * width);
325
+ return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
326
+ }
327
+
328
+ /** Multi-line breakdown with bars, used by the command output. */
329
+ export function formatUsageDetail(snapshot: UsageSnapshot, now = Date.now()): string[] {
330
+ return snapshot.windows.map((window) => {
331
+ const clock = window.resetsAt === null
332
+ ? null
333
+ : formatResetClock(window.resetsAt, { includeDate: USAGE_WINDOW_INCLUDE_DATE[window.key] }, now);
334
+ const reset =
335
+ window.resetsAt === null || clock === null
336
+ ? ""
337
+ : ` ↺ ${formatCountdown(window.resetsAt - now)} - ${clock}`;
338
+ const limited = window.status === "rate-limited" ? " RATE LIMITED" : "";
339
+ return `${window.label.padEnd(3)} ${formatBar(window.remainingPercent)} ${formatPercent(window.remainingPercent).padStart(4)} left${reset}${limited}`;
340
+ });
341
+ }