@aliou/pi-synthetic 0.10.1 → 0.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-synthetic",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -15,50 +15,65 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
15
15
  ctx.ui.notify(MISSING_AUTH_MESSAGE, "warning");
16
16
  return;
17
17
  }
18
+ const key: string = apiKey;
18
19
 
19
20
  const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
20
- const component = new QuotasComponent(theme, () => done(null));
21
-
22
- fetchQuotas(apiKey)
23
- .then((quotas) => {
24
- if (!quotas) {
25
- component.setState({
26
- type: "error",
27
- message:
28
- "Failed to fetch quotas. Check your Synthetic subscription status.",
29
- });
30
- } else {
31
- component.setState({ type: "loaded", quotas });
32
- }
21
+ const controller = new AbortController();
22
+ const component = new QuotasComponent(
23
+ theme,
24
+ tui,
25
+ () => {
26
+ controller.abort();
27
+ done(null);
28
+ },
29
+ () => {
30
+ component.setState({ type: "loading" });
33
31
  tui.requestRender();
34
- })
35
- .catch(() => {
32
+ void loadQuotas();
33
+ },
34
+ );
35
+
36
+ async function loadQuotas(): Promise<void> {
37
+ const fetchResult = await fetchQuotas(key, controller.signal);
38
+ if (controller.signal.aborted) return;
39
+ if (fetchResult.success) {
40
+ component.setState({
41
+ type: "loaded",
42
+ quotas: fetchResult.data.quotas,
43
+ });
44
+ } else {
36
45
  component.setState({
37
46
  type: "error",
38
- message:
39
- "Failed to fetch quotas. Check your Synthetic subscription status.",
47
+ message: fetchResult.error.message,
40
48
  });
41
- tui.requestRender();
42
- });
49
+ }
50
+ tui.requestRender();
51
+ }
52
+
53
+ void loadQuotas();
43
54
 
44
55
  return {
45
56
  render: (width: number) => component.render(width),
46
57
  invalidate: () => component.invalidate(),
47
58
  handleInput: (data: string) => component.handleInput(data),
59
+ dispose: () => {
60
+ controller.abort();
61
+ component.destroy();
62
+ },
48
63
  };
49
64
  });
50
65
 
51
- // RPC fallback: return JSON
66
+ // Non-interactive fallback (RPC, print, JSON modes)
52
67
  if (result === undefined) {
53
- const quotas = await fetchQuotas(apiKey);
54
- if (!quotas) {
68
+ const fetchResult = await fetchQuotas(key);
69
+ if (!fetchResult.success) {
55
70
  ctx.ui.notify(
56
- JSON.stringify({ error: "Failed to fetch quotas" }),
71
+ JSON.stringify({ error: fetchResult.error.message }),
57
72
  "error",
58
73
  );
59
74
  return;
60
75
  }
61
- ctx.ui.notify(JSON.stringify(quotas, null, 2), "info");
76
+ ctx.ui.notify(JSON.stringify(fetchResult.data.quotas), "info");
62
77
  }
63
78
  },
64
79
  });
@@ -1,11 +1,7 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
- import type { Component } from "@mariozechner/pi-tui";
4
- import {
5
- matchesKey,
6
- truncateToWidth,
7
- visibleWidth,
8
- } from "@mariozechner/pi-tui";
3
+ import type { Component, TUI } from "@mariozechner/pi-tui";
4
+ import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
9
5
  import type { QuotasResponse } from "../../../types/quotas";
10
6
 
11
7
  type QuotasState =
@@ -20,10 +16,12 @@ interface QuotaWindow {
20
16
  windowSeconds: number;
21
17
  usedValue: number;
22
18
  limitValue: number;
23
- isCredits?: boolean;
24
- isLimited?: boolean;
25
- tickPercent?: number;
26
- nextRegenCredits?: string;
19
+ isCurrency?: boolean;
20
+ showPace?: boolean;
21
+ paceScale?: number;
22
+ limited?: boolean;
23
+ nextAmount?: string;
24
+ nextLabel?: string;
27
25
  }
28
26
 
29
27
  /** Safely compute percentage, guarding against division by zero */
@@ -41,80 +39,65 @@ function parseCurrency(value: string): number {
41
39
  function toWindows(quotas: QuotasResponse): QuotaWindow[] {
42
40
  const windows: QuotaWindow[] = [];
43
41
 
44
- // Weekly token limit (credits-based)
45
42
  if (quotas.weeklyTokenLimit) {
46
43
  const { weeklyTokenLimit } = quotas;
47
44
  const limitValue = parseCurrency(weeklyTokenLimit.maxCredits);
48
45
  const remainingValue = parseCurrency(weeklyTokenLimit.remainingCredits);
49
46
  windows.push({
50
- label: "Credits",
47
+ label: "Credits / week",
51
48
  usedPercent: Math.max(
52
49
  0,
53
50
  Math.min(100, 100 - weeklyTokenLimit.percentRemaining),
54
51
  ),
55
52
  resetsAt: new Date(weeklyTokenLimit.nextRegenAt),
56
- windowSeconds: 7 * 24 * 60 * 60,
53
+ windowSeconds: 24 * 60 * 60,
57
54
  usedValue: limitValue - remainingValue,
58
55
  limitValue,
59
- isCredits: true,
60
- nextRegenCredits: weeklyTokenLimit.nextRegenCredits,
56
+ isCurrency: true,
57
+ showPace: true,
58
+ paceScale: 1 / 7,
59
+ nextAmount: `+${weeklyTokenLimit.nextRegenCredits}`,
60
+ nextLabel: "Next regen",
61
61
  });
62
62
  }
63
63
 
64
- // Rolling 5-hour limit (request-based)
65
64
  if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
66
65
  const { rollingFiveHourLimit } = quotas;
66
+ const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
67
+ const tickAmount =
68
+ rollingFiveHourLimit.tickPercent * rollingFiveHourLimit.max;
67
69
  windows.push({
68
- label: "5h",
69
- usedPercent: safePercent(
70
- rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
71
- rollingFiveHourLimit.max,
72
- ),
70
+ label: "Requests / 5h",
71
+ usedPercent: safePercent(used, rollingFiveHourLimit.max),
73
72
  resetsAt: new Date(rollingFiveHourLimit.nextTickAt),
74
73
  windowSeconds: 5 * 60 * 60,
75
- usedValue: rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
74
+ usedValue: Math.round(used),
76
75
  limitValue: rollingFiveHourLimit.max,
77
- isLimited: rollingFiveHourLimit.limited,
78
- tickPercent: rollingFiveHourLimit.tickPercent,
79
- });
80
- }
81
-
82
- // Legacy subscription (fallback if rollingFiveHourLimit not available)
83
- if (
84
- !quotas.rollingFiveHourLimit &&
85
- quotas.subscription?.limit &&
86
- quotas.subscription.limit > 0
87
- ) {
88
- windows.push({
89
- label: "Completions",
90
- usedPercent: safePercent(
91
- quotas.subscription.requests,
92
- quotas.subscription.limit,
93
- ),
94
- resetsAt: new Date(quotas.subscription.renewsAt),
95
- windowSeconds: 5 * 60 * 60,
96
- usedValue: quotas.subscription.requests,
97
- limitValue: quotas.subscription.limit,
76
+ showPace: false,
77
+ limited: rollingFiveHourLimit.limited,
78
+ nextAmount: `+${tickAmount.toFixed(1)}`,
79
+ nextLabel: "Next tick",
98
80
  });
99
81
  }
100
82
 
101
83
  if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
84
+ const { hourly } = quotas.search;
102
85
  windows.push({
103
- label: "Search",
104
- usedPercent: safePercent(
105
- quotas.search.hourly.requests,
106
- quotas.search.hourly.limit,
107
- ),
108
- resetsAt: new Date(quotas.search.hourly.renewsAt),
86
+ label: "Search / hour",
87
+ usedPercent: safePercent(hourly.requests, hourly.limit),
88
+ resetsAt: new Date(hourly.renewsAt),
109
89
  windowSeconds: 60 * 60,
110
- usedValue: quotas.search.hourly.requests,
111
- limitValue: quotas.search.hourly.limit,
90
+ usedValue: hourly.requests,
91
+ limitValue: hourly.limit,
92
+ showPace: true,
93
+ paceScale: 1,
94
+ nextLabel: "Resets",
112
95
  });
113
96
  }
114
97
 
115
98
  if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
116
99
  windows.push({
117
- label: "Free Tool Calls",
100
+ label: "Free Tool Calls / day",
118
101
  usedPercent: safePercent(
119
102
  quotas.freeToolCalls.requests,
120
103
  quotas.freeToolCalls.limit,
@@ -123,6 +106,9 @@ function toWindows(quotas: QuotasResponse): QuotaWindow[] {
123
106
  windowSeconds: 24 * 60 * 60,
124
107
  usedValue: quotas.freeToolCalls.requests,
125
108
  limitValue: quotas.freeToolCalls.limit,
109
+ showPace: true,
110
+ paceScale: 1,
111
+ nextLabel: "Resets",
126
112
  });
127
113
  }
128
114
 
@@ -167,29 +153,28 @@ function getSeverity(
167
153
  return "success";
168
154
  }
169
155
 
170
- function formatResetDateTime(date: Date): string {
171
- const now = new Date();
172
- const isToday =
173
- date.getDate() === now.getDate() &&
174
- date.getMonth() === now.getMonth() &&
175
- date.getFullYear() === now.getFullYear();
176
-
177
- const timeStr = date.toLocaleTimeString("en-US", {
178
- hour: "numeric",
179
- minute: "2-digit",
180
- hour12: true,
181
- });
182
-
183
- if (isToday) {
184
- return `today ${timeStr}`;
185
- }
186
-
187
- const dateStr = date.toLocaleDateString("en-US", {
188
- month: "short",
189
- day: "numeric",
190
- });
156
+ function formatTimeRemaining(date: Date): string {
157
+ const ms = date.getTime() - Date.now();
158
+ if (ms <= 0) return "now";
159
+ const totalMins = Math.ceil(ms / (1000 * 60));
160
+ const hours = Math.floor(totalMins / 60);
161
+ const mins = totalMins % 60;
162
+ if (hours >= 1) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
163
+ const totalSecs = Math.ceil(ms / 1000);
164
+ return totalMins >= 1 ? `${totalMins}m` : `${totalSecs}s`;
165
+ }
191
166
 
192
- return `${dateStr} ${timeStr}`;
167
+ /**
168
+ * Convert a foreground ANSI escape to its background equivalent.
169
+ * Handles truecolor (38;2), 256-color (38;5), and basic (3X) escapes.
170
+ */
171
+ function fgAnsiToBg(fgAnsi: string): string {
172
+ // Convert fg escape sequences to bg equivalents by replacing the
173
+ // discriminating digit: 38 (truecolor/256) → 48, 3X (basic) → 4X.
174
+ return fgAnsi
175
+ .split("[38;")
176
+ .join("[48;")
177
+ .replace(/\[3([0-9])m/g, "[4$1m");
193
178
  }
194
179
 
195
180
  function renderProgressBar(
@@ -201,45 +186,40 @@ function renderProgressBar(
201
186
  ): string {
202
187
  const clamped = Math.max(0, Math.min(100, Math.round(percent)));
203
188
  const filled = Math.round((clamped / 100) * width);
204
- const paceIndex =
205
- pacePercent === null || pacePercent === undefined || pacePercent <= percent
206
- ? null
207
- : Math.round((Math.max(0, Math.min(100, pacePercent)) / 100) * width);
208
189
 
209
- const parts: string[] = [];
210
- for (let idx = 0; idx < width; idx++) {
211
- if (idx < filled) {
212
- parts.push(theme.fg(fillColor, "█"));
213
- } else if (paceIndex !== null && idx < paceIndex) {
214
- parts.push(theme.fg(fillColor, "▓"));
215
- } else {
216
- parts.push(theme.fg("dim", "░"));
217
- }
218
- }
190
+ const showPace =
191
+ pacePercent !== null &&
192
+ pacePercent !== undefined &&
193
+ pacePercent >= 5 &&
194
+ Math.abs(pacePercent - percent) >= 5;
195
+ const paceIndex = showPace
196
+ ? Math.min(
197
+ width - 1,
198
+ Math.round(
199
+ (Math.max(0, Math.min(100, pacePercent ?? 0)) / 100) * width,
200
+ ),
201
+ )
202
+ : null;
219
203
 
220
- return parts.join("");
221
- }
204
+ const reset = "\x1b[0m";
222
205
 
223
- function renderSimpleIndicatorBar(
224
- usedPercent: number,
225
- width: number,
226
- theme: Theme,
227
- severity: "success" | "warning" | "error",
228
- ): string {
229
- const clampedPercent = Math.max(0, Math.min(100, usedPercent));
230
- // Clamp to width - 1 to avoid off-by-one when usedPercent === 100
231
- const usedIndex = Math.min(
232
- Math.round((clampedPercent / 100) * width),
233
- width - 1,
234
- );
235
206
  const parts: string[] = [];
236
-
237
- // Hide marker when within 5% of edges
238
- const showMarker = clampedPercent >= 5 && clampedPercent <= 95;
239
-
240
207
  for (let idx = 0; idx < width; idx++) {
241
- if (showMarker && idx === usedIndex) {
242
- parts.push(theme.fg(severity, "|"));
208
+ if (paceIndex !== null && idx === paceIndex) {
209
+ // Inside fill = ahead of pace: accent. Outside = behind pace: severity.
210
+ const markerColor = idx < filled ? "accent" : fillColor;
211
+ // Inside fill: set bg to fill color so `|` doesn't expose the panel bg
212
+ // through the thin character. Outside fill: ░ uses terminal bg naturally,
213
+ // so leave bg unset to match.
214
+ if (idx < filled) {
215
+ const bgAnsi = fgAnsiToBg(theme.getFgAnsi(fillColor));
216
+ const fgAnsi = theme.getFgAnsi(markerColor);
217
+ parts.push(`${bgAnsi}${fgAnsi}|${reset}`);
218
+ } else {
219
+ parts.push(theme.fg(markerColor, "|"));
220
+ }
221
+ } else if (idx < filled) {
222
+ parts.push(theme.fg(fillColor, "█"));
243
223
  } else {
244
224
  parts.push(theme.fg("dim", "░"));
245
225
  }
@@ -251,14 +231,46 @@ function renderSimpleIndicatorBar(
251
231
  export class QuotasComponent implements Component {
252
232
  private state: QuotasState = { type: "loading" };
253
233
  private theme: Theme;
234
+ private tui: TUI;
254
235
  private onClose: () => void;
255
-
256
- constructor(theme: Theme, onClose: () => void) {
236
+ private onRefetch: () => void;
237
+ private loader: Loader | null = null;
238
+
239
+ constructor(
240
+ theme: Theme,
241
+ tui: TUI,
242
+ onClose: () => void,
243
+ onRefetch: () => void,
244
+ ) {
257
245
  this.theme = theme;
246
+ this.tui = tui;
258
247
  this.onClose = onClose;
248
+ this.onRefetch = onRefetch;
249
+ this.startLoader();
250
+ }
251
+
252
+ private startLoader(): void {
253
+ this.loader = new Loader(
254
+ this.tui,
255
+ (s: string) => this.theme.fg("accent", s),
256
+ (s: string) => this.theme.fg("muted", s),
257
+ "Fetching quotas...",
258
+ );
259
+ }
260
+
261
+ destroy(): void {
262
+ this.loader?.stop();
263
+ this.loader = null;
259
264
  }
260
265
 
261
266
  setState(state: QuotasState): void {
267
+ if (state.type === "loading") {
268
+ this.loader?.stop();
269
+ this.startLoader();
270
+ } else if (this.state.type === "loading") {
271
+ this.loader?.stop();
272
+ this.loader = null;
273
+ }
262
274
  this.state = state;
263
275
  }
264
276
 
@@ -267,6 +279,10 @@ export class QuotasComponent implements Component {
267
279
  this.onClose();
268
280
  return true;
269
281
  }
282
+ if (data === "r") {
283
+ this.onRefetch();
284
+ return true;
285
+ }
270
286
  return false;
271
287
  }
272
288
 
@@ -286,7 +302,11 @@ export class QuotasComponent implements Component {
286
302
 
287
303
  switch (this.state.type) {
288
304
  case "loading":
289
- lines.push(this.theme.fg("muted", " Loading..."));
305
+ if (this.loader) {
306
+ lines.push(...this.loader.render(width));
307
+ } else {
308
+ lines.push(this.theme.fg("muted", " Fetching quotas..."));
309
+ }
290
310
  break;
291
311
  case "error":
292
312
  lines.push(this.theme.fg("error", ` ${this.state.message}`));
@@ -299,7 +319,7 @@ export class QuotasComponent implements Component {
299
319
  }
300
320
 
301
321
  lines.push("");
302
- lines.push(this.theme.fg("dim", " q/Esc to close"));
322
+ lines.push(this.theme.fg("dim", " r to refresh q/Esc to close"));
303
323
  lines.push(...border.render(width));
304
324
 
305
325
  return lines;
@@ -335,134 +355,51 @@ export class QuotasComponent implements Component {
335
355
  const lines: string[] = [];
336
356
  const theme = this.theme;
337
357
 
338
- const pacePercent = getPacePercent(window);
358
+ const rawPace = window.showPace ? getPacePercent(window) : null;
359
+ const pacePercent =
360
+ rawPace !== null ? rawPace * (window.paceScale ?? 1) : null;
339
361
  const projectedPercent = getProjectedPercent(
340
362
  window.usedPercent,
341
363
  pacePercent,
342
364
  );
343
- const severity = getSeverity(projectedPercent, pacePercent);
365
+ let severity = getSeverity(projectedPercent, pacePercent);
366
+ if (window.limited) severity = "error";
344
367
 
345
368
  // Label
346
369
  lines.push(
347
370
  truncateToWidth(` ${theme.fg("accent", window.label)}`, maxWidth),
348
371
  );
349
372
 
350
- // Progress bar + usage (or indicator for new quota types)
351
- if (window.isCredits || window.tickPercent !== undefined) {
352
- // Show simple indicator bar for new quota types
353
- const bar = renderSimpleIndicatorBar(
354
- window.usedPercent,
355
- barWidth,
356
- theme,
357
- severity,
358
- );
359
- const usedStr = window.isCredits
360
- ? `$${window.usedValue.toFixed(2)}/$${window.limitValue.toFixed(2)} (${Math.round(window.usedPercent)}%)`
361
- : `${window.usedValue.toFixed(0)}/${window.limitValue.toFixed(0)} (${Math.round(window.usedPercent)}%)`;
362
- const limitedBadge = window.isLimited
363
- ? theme.fg("error", " LIMITED")
364
- : "";
365
- lines.push(
366
- truncateToWidth(
367
- ` ${bar} ${theme.fg(severity, usedStr)}${limitedBadge}`,
368
- maxWidth,
369
- ),
370
- );
371
- } else {
372
- // Traditional progress bar for legacy quota types
373
- const bar = renderProgressBar(
374
- window.usedPercent,
375
- barWidth,
376
- theme,
377
- severity,
378
- pacePercent,
379
- );
380
- const usedStr = `${window.usedValue.toLocaleString()}/${window.limitValue.toLocaleString()} (${Math.round(window.usedPercent)}%)`;
381
- lines.push(
382
- truncateToWidth(` ${bar} ${theme.fg(severity, usedStr)}`, maxWidth),
383
- );
384
- }
385
-
386
- // Metadata: estimated + pace left, reset time right
387
- const leftParts: string[] = [];
388
-
389
- // Show tick info for rolling window
390
- if (window.tickPercent !== undefined) {
391
- const now = Date.now();
392
- const remainingMs = window.resetsAt.getTime() - now;
393
- const remainingMins = Math.ceil(remainingMs / (1000 * 60));
394
- const remainingSecs = Math.ceil(remainingMs / 1000);
395
- const timeStr =
396
- remainingMs <= 0
397
- ? "now"
398
- : remainingMins >= 1
399
- ? `${remainingMins}m`
400
- : `${remainingSecs}s`;
401
- const tickValue = (window.tickPercent / 100) * window.limitValue;
402
- const tickStr = `+${tickValue.toFixed(1)} in ${timeStr}`;
403
- leftParts.push(theme.fg("dim", tickStr));
404
- }
405
-
406
- // Show next regen credits for weekly token limit
407
- if (window.nextRegenCredits !== undefined) {
408
- const now = Date.now();
409
- const remainingMs = window.resetsAt.getTime() - now;
410
- const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60));
411
- const remainingMins = Math.ceil(remainingMs / (1000 * 60));
412
- const timeStr =
413
- remainingMs <= 0
414
- ? "now"
415
- : remainingHours >= 1
416
- ? `${remainingHours}h`
417
- : `${remainingMins}m`;
418
- const regenStr = `+${window.nextRegenCredits} in ${timeStr}`;
419
- leftParts.push(theme.fg("dim", regenStr));
420
- }
373
+ // Bar + usage
374
+ const bar = renderProgressBar(
375
+ window.usedPercent,
376
+ barWidth,
377
+ theme,
378
+ severity,
379
+ pacePercent,
380
+ );
381
+ const usedStr = window.isCurrency
382
+ ? `${Math.round(window.usedPercent)}%/$${window.limitValue.toFixed(2)}`
383
+ : `${Math.round(window.usedPercent)}%/${window.limitValue}`;
384
+ const limitedBadge = window.limited ? theme.fg("error", " LIMITED") : "";
385
+ lines.push(
386
+ truncateToWidth(
387
+ ` ${bar} ${theme.fg(severity, usedStr)}${limitedBadge}`,
388
+ maxWidth,
389
+ ),
390
+ );
421
391
 
422
- if (
423
- projectedPercent > 0 &&
424
- window.tickPercent === undefined &&
425
- window.nextRegenCredits === undefined
426
- ) {
427
- const estStr = `est ${Math.round(projectedPercent)}%`;
428
- leftParts.push(
429
- severity !== "success"
430
- ? theme.fg(severity, estStr)
431
- : theme.fg("dim", estStr),
392
+ // Subtitle: next event info
393
+ if (window.nextLabel) {
394
+ const timeStr = formatTimeRemaining(window.resetsAt);
395
+ const subtitleStr = window.nextAmount
396
+ ? `${window.nextAmount} in ${timeStr}`
397
+ : `${window.nextLabel} in ${timeStr}`;
398
+ lines.push(
399
+ truncateToWidth(` ${theme.fg("dim", subtitleStr)}`, maxWidth),
432
400
  );
433
401
  }
434
402
 
435
- if (
436
- pacePercent !== null &&
437
- window.tickPercent === undefined &&
438
- window.nextRegenCredits === undefined
439
- ) {
440
- const paceDiff = window.usedPercent - pacePercent;
441
- if (Math.abs(paceDiff) > 5) {
442
- if (paceDiff > 0) {
443
- leftParts.push(
444
- theme.fg("warning", `${Math.round(Math.abs(paceDiff))}% ahead`),
445
- );
446
- } else {
447
- leftParts.push(
448
- theme.fg("success", `${Math.round(Math.abs(paceDiff))}% behind`),
449
- );
450
- }
451
- }
452
- }
453
-
454
- const leftStr = leftParts.join(" ");
455
- const resetStr = formatResetDateTime(window.resetsAt);
456
- const rightStr = theme.fg("dim", resetStr);
457
-
458
- const leftW = visibleWidth(leftStr);
459
- const rightW = visibleWidth(rightStr);
460
- const gap = Math.max(2, barWidth - leftW - rightW);
461
-
462
- lines.push(
463
- truncateToWidth(` ${leftStr}${" ".repeat(gap)}${rightStr}`, maxWidth),
464
- );
465
-
466
403
  return lines;
467
404
  }
468
405
 
@@ -107,10 +107,13 @@ async function emitCurrentUsage(
107
107
  ): Promise<void> {
108
108
  const apiKey = await getSyntheticApiKey(authStorage);
109
109
  if (!apiKey) return;
110
- const quotas = await fetchQuotas(apiKey);
111
- if (!quotas) return;
110
+ const result = await fetchQuotas(apiKey);
111
+ if (!result.success) return;
112
112
  pi.events.emit("sub-core:update-current", {
113
- state: { provider: "synthetic", usage: toUsageSnapshot(quotas) },
113
+ state: {
114
+ provider: "synthetic",
115
+ usage: toUsageSnapshot(result.data.quotas),
116
+ },
114
117
  });
115
118
  }
116
119
 
@@ -1,3 +1,14 @@
1
+ export type QuotasErrorKind =
2
+ | "cancelled"
3
+ | "timeout"
4
+ | "config"
5
+ | "http"
6
+ | "network";
7
+
8
+ export type QuotasResult =
9
+ | { success: true; data: { quotas: QuotasResponse } }
10
+ | { success: false; error: { message: string; kind: QuotasErrorKind } };
11
+
1
12
  export interface QuotasResponse {
2
13
  subscription?: {
3
14
  limit: number;
@@ -1,20 +1,73 @@
1
- import type { QuotasResponse } from "../types/quotas";
1
+ import type { QuotasResponse, QuotasResult } from "../types/quotas";
2
+
3
+ const FETCH_TIMEOUT_MS = 15_000;
4
+
5
+ function isTimeoutReason(reason: unknown): boolean {
6
+ return (
7
+ (reason instanceof DOMException && reason.name === "TimeoutError") ||
8
+ (reason instanceof Error && reason.name === "TimeoutError")
9
+ );
10
+ }
2
11
 
3
12
  export async function fetchQuotas(
4
13
  apiKey: string,
5
- ): Promise<QuotasResponse | null> {
6
- if (!apiKey) return null;
14
+ signal?: AbortSignal,
15
+ ): Promise<QuotasResult> {
16
+ if (!apiKey) {
17
+ return {
18
+ success: false,
19
+ error: { message: "No API key provided", kind: "config" },
20
+ };
21
+ }
22
+
23
+ const signals: AbortSignal[] = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
24
+ if (signal) signals.push(signal);
25
+ const combined = AbortSignal.any(signals);
7
26
 
8
27
  try {
9
28
  const response = await fetch("https://api.synthetic.new/v2/quotas", {
10
29
  headers: { Authorization: `Bearer ${apiKey}` },
30
+ signal: combined,
11
31
  });
12
32
 
13
- if (!response.ok) return null;
33
+ if (!response.ok) {
34
+ let message = response.statusText;
35
+ try {
36
+ const body = await response.text();
37
+ if (body) {
38
+ try {
39
+ const parsed = JSON.parse(body) as { error?: string };
40
+ if (parsed.error) message = parsed.error;
41
+ } catch {
42
+ message = body;
43
+ }
44
+ }
45
+ } catch {
46
+ return { success: false, error: { message, kind: "http" } };
47
+ }
48
+ return { success: false, error: { message, kind: "http" } };
49
+ }
50
+
14
51
  const data: QuotasResponse = await response.json();
15
- return data;
16
- } catch {
17
- return null;
52
+ return { success: true, data: { quotas: data } };
53
+ } catch (err: unknown) {
54
+ const isAbort =
55
+ combined.aborted ||
56
+ (err instanceof DOMException && err.name === "AbortError");
57
+ if (isAbort) {
58
+ if (isTimeoutReason(combined.reason)) {
59
+ return {
60
+ success: false,
61
+ error: { message: "Request timed out", kind: "timeout" },
62
+ };
63
+ }
64
+ return {
65
+ success: false,
66
+ error: { message: "Request cancelled", kind: "cancelled" },
67
+ };
68
+ }
69
+ const message = err instanceof Error ? err.message : "Unknown error";
70
+ return { success: false, error: { message, kind: "network" } };
18
71
  }
19
72
  }
20
73