@monotykamary/pi-better-openai 0.1.42 → 0.1.44

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/index.ts CHANGED
@@ -4,7 +4,12 @@
4
4
  * Adds `service_tier: "priority"` to OpenAI provider payloads while fast mode is
5
5
  * enabled and the selected model is in the configured allow-list.
6
6
  */
7
- import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ type Theme,
11
+ type ThemeColor,
12
+ } from "@earendil-works/pi-coding-agent";
8
13
  import {
9
14
  Container,
10
15
  Key,
@@ -58,12 +63,12 @@ import {
58
63
  applySettingToRawConfig,
59
64
  } from "./src/config.ts";
60
65
  import {
66
+ type UsageSegment,
67
+ type UsageSeverity,
61
68
  formatPercent,
62
69
  formatUsageSnapshot,
63
70
  parseUsageSnapshot,
64
71
  readCodexAuth,
65
- requestCodexUsage,
66
- type UsageSnapshot,
67
72
  } from "./src/usage.ts";
68
73
  import {
69
74
  buildBankedResetConfirmation,
@@ -71,10 +76,9 @@ import {
71
76
  formatBankedResetChoice,
72
77
  formatConsumeOutcome,
73
78
  newRedeemRequestId,
74
- requestBankedResetCredits,
75
79
  selectBankedResetCredit,
76
- type BankedResetCredits,
77
80
  } from "./src/resets.ts";
81
+ import { ResetController } from "./src/reset-controller.ts";
78
82
  import { registerOpenAIImage, _imageTest } from "./src/image.ts";
79
83
  import { registerOpenAIWebSearch, _websearchTest } from "./src/websearch.ts";
80
84
  import { registerOpenAILive } from "./src/live/index.ts";
@@ -90,7 +94,7 @@ import {
90
94
  _petsTest,
91
95
  } from "./src/pets.ts";
92
96
  import { FastController, modelList, supportsFast } from "./src/fast-controller.ts";
93
- import { UsageController } from "./src/usage-controller.ts";
97
+ import { isOpenAISubscriptionModel, UsageController } from "./src/usage-controller.ts";
94
98
  import { PetFooterController } from "./src/pet-footer-controller.ts";
95
99
  import {
96
100
  abbreviateHomePath,
@@ -117,6 +121,46 @@ const requireSettingsListTheme = (): SettingsListTheme => {
117
121
  return loadedSettingsListTheme;
118
122
  };
119
123
 
124
+ const USAGE_SEVERITY_COLORS: Record<UsageSeverity, ThemeColor> = {
125
+ ok: "success",
126
+ warning: "warning",
127
+ critical: "error",
128
+ muted: "dim",
129
+ };
130
+
131
+ /**
132
+ * Keep a coloured segment on one line. Unlike sanitizeStatusText this leaves the
133
+ * spacing shared by neighbouring segments ("Usage: " + "5h: " + "90%") intact.
134
+ */
135
+ function flattenSegment(text: string): string {
136
+ return text.replace(/[\r\n\t]+/g, " ");
137
+ }
138
+
139
+ /**
140
+ * Colour a usage line segment by segment: the percentages carry the severity
141
+ * colour while the labels, countdowns and banked resets stay dim.
142
+ */
143
+ function colorizeUsageSegments(segments: UsageSegment[], theme: Theme): string {
144
+ return segments
145
+ .map((segment) =>
146
+ theme.fg(USAGE_SEVERITY_COLORS[segment.severity], flattenSegment(segment.text)),
147
+ )
148
+ .join("");
149
+ }
150
+
151
+ /** Pair the fast-mode segment with the usage segments for the status widget. */
152
+ function statusWidgetParts(
153
+ fast: string | undefined,
154
+ usage: UsageSegment[] | undefined,
155
+ ): UsageSegment[] | undefined {
156
+ const parts: UsageSegment[] = fast ? [{ text: fast, severity: "muted" }] : [];
157
+ if (usage?.length) {
158
+ if (parts.length > 0) parts.push({ text: " · ", severity: "muted" });
159
+ parts.push(...usage);
160
+ }
161
+ return parts.length > 0 ? parts : undefined;
162
+ }
163
+
120
164
  class DynamicBorder {
121
165
  readonly #color: (text: string) => string;
122
166
 
@@ -247,6 +291,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
247
291
  let cachedSessionNameLeafId: string | null | undefined;
248
292
  let cachedSessionName: string | undefined;
249
293
  const usageController = new UsageController(config, updateFooter);
294
+ const resetController = new ResetController();
250
295
  const petController = new PetFooterController(config, updateFooter, () => footerInstalled);
251
296
  let multiproviderService: MultiproviderService | undefined;
252
297
  let unsubscribeMultiprovider: (() => void) | undefined;
@@ -265,6 +310,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
265
310
  setActiveMultiproviderService(value);
266
311
  unsubscribeMultiprovider = value.onActiveAccountChanged(CODEX_PROVIDER_ID, (event) => {
267
312
  void usageController.refresh(event.ctx, undefined, { force: true });
313
+ void resetController.refresh(event.ctx, { force: true }).catch(() => {});
268
314
  updateFooter(event.ctx);
269
315
  });
270
316
  const ctx = multiproviderRefreshCtx;
@@ -402,27 +448,18 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
402
448
  ctx.ui.notify("/openai-resets requires an interactive TUI session.", "warning");
403
449
  return;
404
450
  }
405
- const timeoutSignal = AbortSignal.timeout(15_000);
406
- const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
407
- let credits: BankedResetCredits | undefined;
408
- let snapshot: UsageSnapshot | undefined;
409
- try {
410
- const [creditResult, usageResult] = await Promise.all([
411
- requestBankedResetCredits(ctx, signal),
412
- requestCodexUsage(ctx, signal).catch(() => undefined),
413
- ]);
414
- credits = creditResult;
415
- snapshot = usageResult ? parseUsageSnapshot(usageResult, ctx.model?.id) : undefined;
416
- } catch (error) {
417
- ctx.ui.notify(
418
- `Banked reset lookup failed: ${sanitizeDiagnosticError(
419
- error instanceof Error ? error.message : String(error),
420
- )}`,
421
- "error",
422
- );
451
+ let credits = resetController.snapshot?.credits;
452
+ if (!credits) {
453
+ await resetController.refresh(ctx, { force: true }).catch(() => {});
454
+ credits = resetController.snapshot?.credits;
455
+ }
456
+ if (!credits) {
457
+ const reason = resetController.lastError;
458
+ ctx.ui.notify(`Banked reset lookup failed${reason ? `: ${reason}` : "."}`, "error");
423
459
  return;
424
460
  }
425
- if (!credits || credits.availableCount <= 0) {
461
+ void resetController.refresh(ctx).catch(() => {});
462
+ if (credits.availableCount <= 0) {
426
463
  ctx.ui.notify("No banked Codex resets are available for this account.", "info");
427
464
  return;
428
465
  }
@@ -440,17 +477,20 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
440
477
  const confirmation = buildBankedResetConfirmation({
441
478
  credit: selected,
442
479
  availableCount: credits.availableCount,
443
- snapshot,
480
+ snapshot: usageController.snapshot,
444
481
  });
445
482
  if (!(await ctx.ui.confirm(confirmation.title, confirmation.message))) {
446
483
  ctx.ui.notify("Banked reset redemption cancelled.", "info");
447
484
  return;
448
485
  }
449
486
  try {
487
+ const timeoutSignal = AbortSignal.timeout(15_000);
488
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
450
489
  const result = await consumeBankedReset(ctx, selected?.id, newRedeemRequestId(), signal);
451
490
  const outcome = formatConsumeOutcome(result);
452
491
  ctx.ui.notify(outcome.message, outcome.level);
453
492
  void usageController.refresh(ctx, ctx.model?.id, { force: true });
493
+ void resetController.refresh(ctx, { force: true }).catch(() => {});
454
494
  updateFooter(ctx);
455
495
  } catch (error) {
456
496
  ctx.ui.notify(
@@ -1195,8 +1235,10 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1195
1235
  const petColumnWidth = Math.min(petRenderSizeCells, Math.max(1, width - 1));
1196
1236
  const footerTextWidth = inlinePet ? Math.max(1, width - petColumnWidth - 2) : width;
1197
1237
 
1198
- const usageStatusLine = usageController.statusLine(ctx, cfg, usingSubscription);
1199
- const usageLine = usageStatusLine ? theme.fg("dim", usageStatusLine) : undefined;
1238
+ const usageStatusSegments = usageController.statusSegments(ctx, cfg, usingSubscription);
1239
+ const usageLine = usageStatusSegments
1240
+ ? colorizeUsageSegments(usageStatusSegments, theme)
1241
+ : undefined;
1200
1242
 
1201
1243
  let statsLeft = parts.join(" ");
1202
1244
  let statsLeftWidth = visibleWidth(statsLeft);
@@ -1322,22 +1364,22 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1322
1364
  statusInstalled = text !== undefined;
1323
1365
  }
1324
1366
 
1325
- function setStatusWidget(ctx: ExtensionContext, text: string | undefined): void {
1326
- if (!text && !statusWidgetInstalled) return;
1367
+ function setStatusWidget(ctx: ExtensionContext, parts: UsageSegment[] | undefined): void {
1368
+ if (!parts && !statusWidgetInstalled) return;
1327
1369
  ctx.ui.setWidget(
1328
1370
  STATUS_KEY,
1329
- text
1371
+ parts
1330
1372
  ? (_tui, theme) => ({
1331
1373
  invalidate() {},
1332
1374
  render(width: number): string[] {
1333
- const line = theme.fg("dim", sanitizeStatusText(text));
1375
+ const line = colorizeUsageSegments(parts, theme);
1334
1376
  return [truncateToWidth(line, width, theme.fg("dim", "..."))];
1335
1377
  },
1336
1378
  })
1337
1379
  : undefined,
1338
1380
  { placement: "belowEditor" },
1339
1381
  );
1340
- statusWidgetInstalled = text !== undefined;
1382
+ statusWidgetInstalled = parts !== undefined;
1341
1383
  }
1342
1384
 
1343
1385
  function updateFooter(ctx: ExtensionContext): void {
@@ -1373,8 +1415,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1373
1415
  }
1374
1416
 
1375
1417
  const fast = fastController.statusSegment(ctx, cfg);
1376
- const usage = usageController.statusLine(ctx, cfg);
1377
- setStatusWidget(ctx, [fast, usage].filter(Boolean).join(" · ") || undefined);
1418
+ const usage = usageController.statusSegments(ctx, cfg);
1419
+ setStatusWidget(ctx, statusWidgetParts(fast, usage));
1378
1420
  }
1379
1421
 
1380
1422
  pi.on("session_start", (_event, ctx) => {
@@ -1393,6 +1435,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1393
1435
  updateFooter(ctx);
1394
1436
  if (hasTerminalUI(ctx) && nextConfig.pets.enabled) void petController.refresh(ctx, nextConfig);
1395
1437
  usageController.start(ctx);
1438
+ if (nextConfig.usage.enabled && isOpenAISubscriptionModel(ctx, nextConfig))
1439
+ resetController.start(ctx);
1396
1440
  if (fastController.active) ctx.ui.notify(fastController.stateText(ctx, nextConfig), "info");
1397
1441
  });
1398
1442
 
@@ -1469,6 +1513,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1469
1513
  invalidateSessionName();
1470
1514
  multiproviderRefreshCtx = undefined;
1471
1515
  usageController.shutdown();
1516
+ resetController.stop();
1472
1517
  petController.shutdown();
1473
1518
  });
1474
1519
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-openai",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Improve OpenAI in pi with fast mode, usage stats, realtime voice, image generation, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
@@ -0,0 +1,97 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeDiagnosticError } from "./format.ts";
3
+ import { type BankedResetCredits, requestBankedResetCredits } from "./resets.ts";
4
+
5
+ export const BANKED_RESET_CACHE_TTL_MS = 5 * 60_000;
6
+ const BANKED_RESET_REQUEST_TIMEOUT_MS = 10_000;
7
+
8
+ export type BankedResetCache = {
9
+ credits: BankedResetCredits;
10
+ updatedAt: number;
11
+ };
12
+
13
+ // Keeps the banked-reset credit list warm so /openai-resets opens without
14
+ // paying the credential-resolution + wham round trip. The cache is prefetched
15
+ // at session start, refreshed on a TTL, and served even when stale (a stale
16
+ // open still kicks off a background refresh; the backend re-validates every
17
+ // redemption server-side, so a stale offer can never be consumed twice).
18
+ export class ResetController {
19
+ private cache: BankedResetCache | undefined;
20
+ private error: string | undefined;
21
+ private lastFetchAt: number | undefined;
22
+ private inFlight: Promise<void> | undefined;
23
+ private timer: ReturnType<typeof setInterval> | undefined;
24
+ private sessionSignal: AbortSignal | undefined;
25
+ private sessionAbortHandler: (() => void) | undefined;
26
+
27
+ get snapshot(): BankedResetCache | undefined {
28
+ return this.cache;
29
+ }
30
+
31
+ get lastError(): string | undefined {
32
+ return this.error;
33
+ }
34
+
35
+ isFresh(now = Date.now()): boolean {
36
+ return this.cache !== undefined && now - this.cache.updatedAt < BANKED_RESET_CACHE_TTL_MS;
37
+ }
38
+
39
+ refresh(ctx: ExtensionContext, options?: { force?: boolean }): Promise<void> {
40
+ if (this.inFlight) return this.inFlight;
41
+ if (
42
+ !options?.force &&
43
+ this.lastFetchAt !== undefined &&
44
+ Date.now() - this.lastFetchAt < BANKED_RESET_CACHE_TTL_MS
45
+ )
46
+ return Promise.resolve();
47
+ this.lastFetchAt = Date.now();
48
+ const timeoutSignal = AbortSignal.timeout(BANKED_RESET_REQUEST_TIMEOUT_MS);
49
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
50
+ const task = (async () => {
51
+ try {
52
+ const credits = await requestBankedResetCredits(ctx, signal);
53
+ if (credits) {
54
+ this.cache = { credits, updatedAt: Date.now() };
55
+ this.error = undefined;
56
+ } else {
57
+ this.error = "OpenAI Codex credentials unavailable.";
58
+ }
59
+ } catch (error) {
60
+ this.error = sanitizeDiagnosticError(
61
+ error instanceof Error ? error.message : String(error),
62
+ );
63
+ } finally {
64
+ this.inFlight = undefined;
65
+ }
66
+ })();
67
+ this.inFlight = task;
68
+ return task;
69
+ }
70
+
71
+ start(ctx: ExtensionContext): void {
72
+ this.stop();
73
+ if (ctx.signal?.aborted) return;
74
+ this.sessionSignal = ctx.signal;
75
+ this.sessionAbortHandler = () => this.stop();
76
+ ctx.signal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
77
+ void this.refresh(ctx).catch(() => {});
78
+ this.timer = setInterval(() => {
79
+ if (ctx.signal?.aborted) {
80
+ this.stop();
81
+ return;
82
+ }
83
+ void this.refresh(ctx).catch(() => {});
84
+ }, BANKED_RESET_CACHE_TTL_MS);
85
+ this.timer.unref?.();
86
+ }
87
+
88
+ stop(): void {
89
+ if (this.timer) clearInterval(this.timer);
90
+ this.timer = undefined;
91
+ if (this.sessionSignal && this.sessionAbortHandler) {
92
+ this.sessionSignal.removeEventListener("abort", this.sessionAbortHandler);
93
+ }
94
+ this.sessionSignal = undefined;
95
+ this.sessionAbortHandler = undefined;
96
+ }
97
+ }
@@ -3,6 +3,7 @@ import type { ResolvedConfig } from "./config.ts";
3
3
  import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
4
4
  import {
5
5
  AUTH_FILE,
6
+ type UsageSegment,
6
7
  type UsageSnapshot,
7
8
  formatResetCountdown,
8
9
  formatUsageSnapshot,
@@ -10,6 +11,7 @@ import {
10
11
  readCodexAuth,
11
12
  requestCodexUsage,
12
13
  usageScopeForModel,
14
+ usageSegments,
13
15
  } from "./usage.ts";
14
16
  import { currentModelKey } from "./fast-controller.ts";
15
17
 
@@ -70,20 +72,30 @@ export class UsageController {
70
72
  return this.usageSnapshot;
71
73
  }
72
74
 
73
- statusLine(
75
+ statusSegments(
74
76
  ctx: ExtensionContext,
75
77
  cfg = this.getConfig(ctx),
76
78
  isUsingOAuth?: boolean,
77
- ): string | undefined {
79
+ ): UsageSegment[] | undefined {
78
80
  return this.usageSnapshot &&
79
81
  !this.usageError &&
80
82
  this.usageSnapshot.scope === usageScopeForModel(ctx.model?.id) &&
81
83
  cfg.usage.enabled &&
82
84
  isOpenAISubscriptionModel(ctx, cfg, isUsingOAuth)
83
- ? formatUsageSnapshot(this.usageSnapshot, cfg.usage)
85
+ ? usageSegments(this.usageSnapshot, cfg.usage)
84
86
  : undefined;
85
87
  }
86
88
 
89
+ statusLine(
90
+ ctx: ExtensionContext,
91
+ cfg = this.getConfig(ctx),
92
+ isUsingOAuth?: boolean,
93
+ ): string | undefined {
94
+ return this.statusSegments(ctx, cfg, isUsingOAuth)
95
+ ?.map((segment) => segment.text)
96
+ .join("");
97
+ }
98
+
87
99
  formatStatus(ctx: ExtensionContext): string {
88
100
  const cfg = this.getConfig(ctx);
89
101
  if (!cfg.usage.enabled) return "Usage display is disabled.";
package/src/usage.ts CHANGED
@@ -253,11 +253,29 @@ export function formatPercent(value: number | null): string {
253
253
  : "--";
254
254
  }
255
255
 
256
- export function formatUsageSnapshot(
256
+ export type UsageSeverity = "ok" | "warning" | "critical" | "muted";
257
+
258
+ export type UsageSegment = {
259
+ text: string;
260
+ severity: UsageSeverity;
261
+ };
262
+
263
+ /** Remaining budget at or below these thresholds turns the percentage amber/red. */
264
+ const WARNING_LEFT_PERCENT = 30;
265
+ const CRITICAL_LEFT_PERCENT = 10;
266
+
267
+ export function severityForLeftPercent(percent: number | null): UsageSeverity {
268
+ if (percent === null) return "muted";
269
+ if (percent <= CRITICAL_LEFT_PERCENT) return "critical";
270
+ if (percent <= WARNING_LEFT_PERCENT) return "warning";
271
+ return "ok";
272
+ }
273
+
274
+ export function usageSegments(
257
275
  snapshot: UsageSnapshot,
258
276
  options: { showResetTimes: boolean; showBankedResets?: boolean },
259
277
  now = Date.now(),
260
- ): string {
278
+ ): UsageSegment[] {
261
279
  const windows = [
262
280
  {
263
281
  label: "5h",
@@ -276,24 +294,41 @@ export function formatUsageSnapshot(
276
294
  (window) => window.percent !== null || window.resetSeconds !== null,
277
295
  );
278
296
  const displayedWindows = availableWindows.length > 0 ? availableWindows : windows;
279
- const usage = displayedWindows
280
- .map((window) => `${window.label}: ${formatPercent(window.percent)}`)
281
- .join(" · ");
282
- const resets = options.showResetTimes
283
- ? displayedWindows
284
- .map((window) =>
285
- formatCompactReset(
286
- displayedWindows.length > 1 ? window.label : undefined,
287
- remainingResetSeconds(window.resetSeconds, snapshot.capturedAt, now),
288
- window.includeDate ? { includeDate: true } : undefined,
289
- now,
290
- ),
291
- )
292
- .filter((value): value is string => value !== null)
293
- : [];
297
+ const segments: UsageSegment[] = [{ text: "Usage: ", severity: "muted" }];
298
+ displayedWindows.forEach((window, index) => {
299
+ if (index > 0) segments.push({ text: " · ", severity: "muted" });
300
+ segments.push({ text: `${window.label}: `, severity: "muted" });
301
+ segments.push({
302
+ text: formatPercent(window.percent),
303
+ severity: severityForLeftPercent(window.percent),
304
+ });
305
+ });
306
+ if (options.showResetTimes) {
307
+ for (const window of displayedWindows) {
308
+ const reset = formatCompactReset(
309
+ displayedWindows.length > 1 ? window.label : undefined,
310
+ remainingResetSeconds(window.resetSeconds, snapshot.capturedAt, now),
311
+ window.includeDate ? { includeDate: true } : undefined,
312
+ now,
313
+ );
314
+ if (reset) segments.push({ text: ` · ${reset}`, severity: "muted" });
315
+ }
316
+ }
294
317
  const banked =
295
318
  options.showBankedResets === false ? null : formatBankedResetsSuffix(snapshot.bankedResets);
296
- return `Usage: ${usage}${resets.length ? ` · ${resets.join(" · ")}` : ""}${banked ? ` · ${banked}` : ""}`;
319
+ if (banked) segments.push({ text: ` · ${banked}`, severity: "muted" });
320
+ return segments;
321
+ }
322
+
323
+ /** Flat text form of {@link usageSegments}, used for status lines and notifications. */
324
+ export function formatUsageSnapshot(
325
+ snapshot: UsageSnapshot,
326
+ options: { showResetTimes: boolean; showBankedResets?: boolean },
327
+ now = Date.now(),
328
+ ): string {
329
+ return usageSegments(snapshot, options, now)
330
+ .map((segment) => segment.text)
331
+ .join("");
297
332
  }
298
333
 
299
334
  export function formatBankedResetsSuffix(count: number | null): string | null {