@aliou/pi-synthetic 0.10.2 → 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.2",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -19,10 +19,19 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
19
19
 
20
20
  const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
21
21
  const controller = new AbortController();
22
- const component = new QuotasComponent(theme, tui, () => {
23
- controller.abort();
24
- done(null);
25
- });
22
+ const component = new QuotasComponent(
23
+ theme,
24
+ tui,
25
+ () => {
26
+ controller.abort();
27
+ done(null);
28
+ },
29
+ () => {
30
+ component.setState({ type: "loading" });
31
+ tui.requestRender();
32
+ void loadQuotas();
33
+ },
34
+ );
26
35
 
27
36
  async function loadQuotas(): Promise<void> {
28
37
  const fetchResult = await fetchQuotas(key, controller.signal);
@@ -1,12 +1,7 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
3
  import type { Component, TUI } from "@mariozechner/pi-tui";
4
- import {
5
- Loader,
6
- matchesKey,
7
- truncateToWidth,
8
- visibleWidth,
9
- } from "@mariozechner/pi-tui";
4
+ import { Loader, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
10
5
  import type { QuotasResponse } from "../../../types/quotas";
11
6
 
12
7
  type QuotasState =
@@ -21,10 +16,12 @@ interface QuotaWindow {
21
16
  windowSeconds: number;
22
17
  usedValue: number;
23
18
  limitValue: number;
24
- isCredits?: boolean;
25
- isLimited?: boolean;
26
- tickPercent?: number;
27
- nextRegenCredits?: string;
19
+ isCurrency?: boolean;
20
+ showPace?: boolean;
21
+ paceScale?: number;
22
+ limited?: boolean;
23
+ nextAmount?: string;
24
+ nextLabel?: string;
28
25
  }
29
26
 
30
27
  /** Safely compute percentage, guarding against division by zero */
@@ -42,80 +39,65 @@ function parseCurrency(value: string): number {
42
39
  function toWindows(quotas: QuotasResponse): QuotaWindow[] {
43
40
  const windows: QuotaWindow[] = [];
44
41
 
45
- // Weekly token limit (credits-based)
46
42
  if (quotas.weeklyTokenLimit) {
47
43
  const { weeklyTokenLimit } = quotas;
48
44
  const limitValue = parseCurrency(weeklyTokenLimit.maxCredits);
49
45
  const remainingValue = parseCurrency(weeklyTokenLimit.remainingCredits);
50
46
  windows.push({
51
- label: "Credits",
47
+ label: "Credits / week",
52
48
  usedPercent: Math.max(
53
49
  0,
54
50
  Math.min(100, 100 - weeklyTokenLimit.percentRemaining),
55
51
  ),
56
52
  resetsAt: new Date(weeklyTokenLimit.nextRegenAt),
57
- windowSeconds: 7 * 24 * 60 * 60,
53
+ windowSeconds: 24 * 60 * 60,
58
54
  usedValue: limitValue - remainingValue,
59
55
  limitValue,
60
- isCredits: true,
61
- nextRegenCredits: weeklyTokenLimit.nextRegenCredits,
56
+ isCurrency: true,
57
+ showPace: true,
58
+ paceScale: 1 / 7,
59
+ nextAmount: `+${weeklyTokenLimit.nextRegenCredits}`,
60
+ nextLabel: "Next regen",
62
61
  });
63
62
  }
64
63
 
65
- // Rolling 5-hour limit (request-based)
66
64
  if (quotas.rollingFiveHourLimit && quotas.rollingFiveHourLimit.max > 0) {
67
65
  const { rollingFiveHourLimit } = quotas;
66
+ const used = rollingFiveHourLimit.max - rollingFiveHourLimit.remaining;
67
+ const tickAmount =
68
+ rollingFiveHourLimit.tickPercent * rollingFiveHourLimit.max;
68
69
  windows.push({
69
- label: "5h",
70
- usedPercent: safePercent(
71
- rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
72
- rollingFiveHourLimit.max,
73
- ),
70
+ label: "Requests / 5h",
71
+ usedPercent: safePercent(used, rollingFiveHourLimit.max),
74
72
  resetsAt: new Date(rollingFiveHourLimit.nextTickAt),
75
73
  windowSeconds: 5 * 60 * 60,
76
- usedValue: rollingFiveHourLimit.max - rollingFiveHourLimit.remaining,
74
+ usedValue: Math.round(used),
77
75
  limitValue: rollingFiveHourLimit.max,
78
- isLimited: rollingFiveHourLimit.limited,
79
- tickPercent: rollingFiveHourLimit.tickPercent,
80
- });
81
- }
82
-
83
- // Legacy subscription (fallback if rollingFiveHourLimit not available)
84
- if (
85
- !quotas.rollingFiveHourLimit &&
86
- quotas.subscription?.limit &&
87
- quotas.subscription.limit > 0
88
- ) {
89
- windows.push({
90
- label: "Completions",
91
- usedPercent: safePercent(
92
- quotas.subscription.requests,
93
- quotas.subscription.limit,
94
- ),
95
- resetsAt: new Date(quotas.subscription.renewsAt),
96
- windowSeconds: 5 * 60 * 60,
97
- usedValue: quotas.subscription.requests,
98
- limitValue: quotas.subscription.limit,
76
+ showPace: false,
77
+ limited: rollingFiveHourLimit.limited,
78
+ nextAmount: `+${tickAmount.toFixed(1)}`,
79
+ nextLabel: "Next tick",
99
80
  });
100
81
  }
101
82
 
102
83
  if (quotas.search?.hourly?.limit && quotas.search.hourly.limit > 0) {
84
+ const { hourly } = quotas.search;
103
85
  windows.push({
104
- label: "Search",
105
- usedPercent: safePercent(
106
- quotas.search.hourly.requests,
107
- quotas.search.hourly.limit,
108
- ),
109
- resetsAt: new Date(quotas.search.hourly.renewsAt),
86
+ label: "Search / hour",
87
+ usedPercent: safePercent(hourly.requests, hourly.limit),
88
+ resetsAt: new Date(hourly.renewsAt),
110
89
  windowSeconds: 60 * 60,
111
- usedValue: quotas.search.hourly.requests,
112
- limitValue: quotas.search.hourly.limit,
90
+ usedValue: hourly.requests,
91
+ limitValue: hourly.limit,
92
+ showPace: true,
93
+ paceScale: 1,
94
+ nextLabel: "Resets",
113
95
  });
114
96
  }
115
97
 
116
98
  if (quotas.freeToolCalls?.limit && quotas.freeToolCalls.limit > 0) {
117
99
  windows.push({
118
- label: "Free Tool Calls",
100
+ label: "Free Tool Calls / day",
119
101
  usedPercent: safePercent(
120
102
  quotas.freeToolCalls.requests,
121
103
  quotas.freeToolCalls.limit,
@@ -124,6 +106,9 @@ function toWindows(quotas: QuotasResponse): QuotaWindow[] {
124
106
  windowSeconds: 24 * 60 * 60,
125
107
  usedValue: quotas.freeToolCalls.requests,
126
108
  limitValue: quotas.freeToolCalls.limit,
109
+ showPace: true,
110
+ paceScale: 1,
111
+ nextLabel: "Resets",
127
112
  });
128
113
  }
129
114
 
@@ -168,29 +153,28 @@ function getSeverity(
168
153
  return "success";
169
154
  }
170
155
 
171
- function formatResetDateTime(date: Date): string {
172
- const now = new Date();
173
- const isToday =
174
- date.getDate() === now.getDate() &&
175
- date.getMonth() === now.getMonth() &&
176
- date.getFullYear() === now.getFullYear();
177
-
178
- const timeStr = date.toLocaleTimeString("en-US", {
179
- hour: "numeric",
180
- minute: "2-digit",
181
- hour12: true,
182
- });
183
-
184
- if (isToday) {
185
- return `today ${timeStr}`;
186
- }
187
-
188
- const dateStr = date.toLocaleDateString("en-US", {
189
- month: "short",
190
- day: "numeric",
191
- });
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
+ }
192
166
 
193
- 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");
194
178
  }
195
179
 
196
180
  function renderProgressBar(
@@ -202,45 +186,40 @@ function renderProgressBar(
202
186
  ): string {
203
187
  const clamped = Math.max(0, Math.min(100, Math.round(percent)));
204
188
  const filled = Math.round((clamped / 100) * width);
205
- const paceIndex =
206
- pacePercent === null || pacePercent === undefined || pacePercent <= percent
207
- ? null
208
- : Math.round((Math.max(0, Math.min(100, pacePercent)) / 100) * width);
209
189
 
210
- const parts: string[] = [];
211
- for (let idx = 0; idx < width; idx++) {
212
- if (idx < filled) {
213
- parts.push(theme.fg(fillColor, "█"));
214
- } else if (paceIndex !== null && idx < paceIndex) {
215
- parts.push(theme.fg(fillColor, "▓"));
216
- } else {
217
- parts.push(theme.fg("dim", "░"));
218
- }
219
- }
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;
220
203
 
221
- return parts.join("");
222
- }
204
+ const reset = "\x1b[0m";
223
205
 
224
- function renderSimpleIndicatorBar(
225
- usedPercent: number,
226
- width: number,
227
- theme: Theme,
228
- severity: "success" | "warning" | "error",
229
- ): string {
230
- const clampedPercent = Math.max(0, Math.min(100, usedPercent));
231
- // Clamp to width - 1 to avoid off-by-one when usedPercent === 100
232
- const usedIndex = Math.min(
233
- Math.round((clampedPercent / 100) * width),
234
- width - 1,
235
- );
236
206
  const parts: string[] = [];
237
-
238
- // Hide marker when within 5% of edges
239
- const showMarker = clampedPercent >= 5 && clampedPercent <= 95;
240
-
241
207
  for (let idx = 0; idx < width; idx++) {
242
- if (showMarker && idx === usedIndex) {
243
- 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, "█"));
244
223
  } else {
245
224
  parts.push(theme.fg("dim", "░"));
246
225
  }
@@ -254,12 +233,19 @@ export class QuotasComponent implements Component {
254
233
  private theme: Theme;
255
234
  private tui: TUI;
256
235
  private onClose: () => void;
236
+ private onRefetch: () => void;
257
237
  private loader: Loader | null = null;
258
238
 
259
- constructor(theme: Theme, tui: TUI, onClose: () => void) {
239
+ constructor(
240
+ theme: Theme,
241
+ tui: TUI,
242
+ onClose: () => void,
243
+ onRefetch: () => void,
244
+ ) {
260
245
  this.theme = theme;
261
246
  this.tui = tui;
262
247
  this.onClose = onClose;
248
+ this.onRefetch = onRefetch;
263
249
  this.startLoader();
264
250
  }
265
251
 
@@ -278,7 +264,10 @@ export class QuotasComponent implements Component {
278
264
  }
279
265
 
280
266
  setState(state: QuotasState): void {
281
- if (this.state.type === "loading" && state.type !== "loading") {
267
+ if (state.type === "loading") {
268
+ this.loader?.stop();
269
+ this.startLoader();
270
+ } else if (this.state.type === "loading") {
282
271
  this.loader?.stop();
283
272
  this.loader = null;
284
273
  }
@@ -290,6 +279,10 @@ export class QuotasComponent implements Component {
290
279
  this.onClose();
291
280
  return true;
292
281
  }
282
+ if (data === "r") {
283
+ this.onRefetch();
284
+ return true;
285
+ }
293
286
  return false;
294
287
  }
295
288
 
@@ -326,7 +319,7 @@ export class QuotasComponent implements Component {
326
319
  }
327
320
 
328
321
  lines.push("");
329
- lines.push(this.theme.fg("dim", " q/Esc to close"));
322
+ lines.push(this.theme.fg("dim", " r to refresh q/Esc to close"));
330
323
  lines.push(...border.render(width));
331
324
 
332
325
  return lines;
@@ -362,134 +355,51 @@ export class QuotasComponent implements Component {
362
355
  const lines: string[] = [];
363
356
  const theme = this.theme;
364
357
 
365
- const pacePercent = getPacePercent(window);
358
+ const rawPace = window.showPace ? getPacePercent(window) : null;
359
+ const pacePercent =
360
+ rawPace !== null ? rawPace * (window.paceScale ?? 1) : null;
366
361
  const projectedPercent = getProjectedPercent(
367
362
  window.usedPercent,
368
363
  pacePercent,
369
364
  );
370
- const severity = getSeverity(projectedPercent, pacePercent);
365
+ let severity = getSeverity(projectedPercent, pacePercent);
366
+ if (window.limited) severity = "error";
371
367
 
372
368
  // Label
373
369
  lines.push(
374
370
  truncateToWidth(` ${theme.fg("accent", window.label)}`, maxWidth),
375
371
  );
376
372
 
377
- // Progress bar + usage (or indicator for new quota types)
378
- if (window.isCredits || window.tickPercent !== undefined) {
379
- // Show simple indicator bar for new quota types
380
- const bar = renderSimpleIndicatorBar(
381
- window.usedPercent,
382
- barWidth,
383
- theme,
384
- severity,
385
- );
386
- const usedStr = window.isCredits
387
- ? `$${window.usedValue.toFixed(2)}/$${window.limitValue.toFixed(2)} (${Math.round(window.usedPercent)}%)`
388
- : `${window.usedValue.toFixed(0)}/${window.limitValue.toFixed(0)} (${Math.round(window.usedPercent)}%)`;
389
- const limitedBadge = window.isLimited
390
- ? theme.fg("error", " LIMITED")
391
- : "";
392
- lines.push(
393
- truncateToWidth(
394
- ` ${bar} ${theme.fg(severity, usedStr)}${limitedBadge}`,
395
- maxWidth,
396
- ),
397
- );
398
- } else {
399
- // Traditional progress bar for legacy quota types
400
- const bar = renderProgressBar(
401
- window.usedPercent,
402
- barWidth,
403
- theme,
404
- severity,
405
- pacePercent,
406
- );
407
- const usedStr = `${window.usedValue.toLocaleString()}/${window.limitValue.toLocaleString()} (${Math.round(window.usedPercent)}%)`;
408
- lines.push(
409
- truncateToWidth(` ${bar} ${theme.fg(severity, usedStr)}`, maxWidth),
410
- );
411
- }
412
-
413
- // Metadata: estimated + pace left, reset time right
414
- const leftParts: string[] = [];
415
-
416
- // Show tick info for rolling window
417
- if (window.tickPercent !== undefined) {
418
- const now = Date.now();
419
- const remainingMs = window.resetsAt.getTime() - now;
420
- const remainingMins = Math.ceil(remainingMs / (1000 * 60));
421
- const remainingSecs = Math.ceil(remainingMs / 1000);
422
- const timeStr =
423
- remainingMs <= 0
424
- ? "now"
425
- : remainingMins >= 1
426
- ? `${remainingMins}m`
427
- : `${remainingSecs}s`;
428
- const tickValue = (window.tickPercent / 100) * window.limitValue;
429
- const tickStr = `+${tickValue.toFixed(1)} in ${timeStr}`;
430
- leftParts.push(theme.fg("dim", tickStr));
431
- }
432
-
433
- // Show next regen credits for weekly token limit
434
- if (window.nextRegenCredits !== undefined) {
435
- const now = Date.now();
436
- const remainingMs = window.resetsAt.getTime() - now;
437
- const remainingHours = Math.ceil(remainingMs / (1000 * 60 * 60));
438
- const remainingMins = Math.ceil(remainingMs / (1000 * 60));
439
- const timeStr =
440
- remainingMs <= 0
441
- ? "now"
442
- : remainingHours >= 1
443
- ? `${remainingHours}h`
444
- : `${remainingMins}m`;
445
- const regenStr = `+${window.nextRegenCredits} in ${timeStr}`;
446
- leftParts.push(theme.fg("dim", regenStr));
447
- }
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
+ );
448
391
 
449
- if (
450
- projectedPercent > 0 &&
451
- window.tickPercent === undefined &&
452
- window.nextRegenCredits === undefined
453
- ) {
454
- const estStr = `est ${Math.round(projectedPercent)}%`;
455
- leftParts.push(
456
- severity !== "success"
457
- ? theme.fg(severity, estStr)
458
- : 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),
459
400
  );
460
401
  }
461
402
 
462
- if (
463
- pacePercent !== null &&
464
- window.tickPercent === undefined &&
465
- window.nextRegenCredits === undefined
466
- ) {
467
- const paceDiff = window.usedPercent - pacePercent;
468
- if (Math.abs(paceDiff) > 5) {
469
- if (paceDiff > 0) {
470
- leftParts.push(
471
- theme.fg("warning", `${Math.round(Math.abs(paceDiff))}% ahead`),
472
- );
473
- } else {
474
- leftParts.push(
475
- theme.fg("success", `${Math.round(Math.abs(paceDiff))}% behind`),
476
- );
477
- }
478
- }
479
- }
480
-
481
- const leftStr = leftParts.join(" ");
482
- const resetStr = formatResetDateTime(window.resetsAt);
483
- const rightStr = theme.fg("dim", resetStr);
484
-
485
- const leftW = visibleWidth(leftStr);
486
- const rightW = visibleWidth(rightStr);
487
- const gap = Math.max(2, barWidth - leftW - rightW);
488
-
489
- lines.push(
490
- truncateToWidth(` ${leftStr}${" ".repeat(gap)}${rightStr}`, maxWidth),
491
- );
492
-
493
403
  return lines;
494
404
  }
495
405