@aliou/pi-synthetic 0.4.6 → 0.5.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 +24 -7
- package/src/commands/quotas.ts +19 -85
- package/src/components/quotas-display.ts +211 -110
- package/src/components/tabbed-panel.ts +161 -0
- package/src/hooks/search-tool-availability.ts +2 -27
- package/src/hooks/sub-integration.ts +145 -0
- package/src/index.ts +2 -0
- package/src/providers/models.ts +15 -0
- package/src/types/quotas.ts +12 -0
- package/src/utils/quotas.ts +33 -0
- package/.changeset/config.json +0 -11
- package/.github/workflows/ci.yml +0 -30
- package/.github/workflows/publish.yml +0 -151
- package/.husky/pre-commit +0 -3
- package/AGENTS.md +0 -69
- package/CHANGELOG.md +0 -91
- package/biome.json +0 -30
- package/shell.nix +0 -10
- package/tsconfig.json +0 -15
package/package.json
CHANGED
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-synthetic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi"
|
|
11
|
+
],
|
|
4
12
|
"repository": {
|
|
5
13
|
"type": "git",
|
|
6
14
|
"url": "https://github.com/aliou/pi-synthetic"
|
|
7
15
|
},
|
|
8
|
-
"keywords": [
|
|
9
|
-
"pi-package"
|
|
10
|
-
],
|
|
11
16
|
"pi": {
|
|
12
17
|
"extensions": [
|
|
13
18
|
"./src/index.ts"
|
|
14
19
|
],
|
|
15
20
|
"video": "https://assets.aliou.me/pi-extensions/demos/pi-synthetic.mp4"
|
|
16
21
|
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
17
29
|
"peerDependencies": {
|
|
18
|
-
"@mariozechner/pi-coding-agent": ">=0.52.7"
|
|
30
|
+
"@mariozechner/pi-coding-agent": ">=0.52.7",
|
|
31
|
+
"@mariozechner/pi-tui": ">=0.51.0"
|
|
19
32
|
},
|
|
20
33
|
"devDependencies": {
|
|
21
|
-
"@
|
|
34
|
+
"@aliou/biome-plugins": "^0.3.2",
|
|
35
|
+
"@biomejs/biome": "^2.4.2",
|
|
22
36
|
"@changesets/cli": "^2.27.11",
|
|
23
37
|
"@mariozechner/pi-coding-agent": "0.52.7",
|
|
24
|
-
"@mariozechner/pi-tui": "0.52.7",
|
|
25
38
|
"@sinclair/typebox": "^0.34.48",
|
|
26
39
|
"@types/node": "^25.0.10",
|
|
27
40
|
"husky": "^9.1.7",
|
|
@@ -30,12 +43,16 @@
|
|
|
30
43
|
"peerDependenciesMeta": {
|
|
31
44
|
"@mariozechner/pi-coding-agent": {
|
|
32
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"@mariozechner/pi-tui": {
|
|
48
|
+
"optional": true
|
|
33
49
|
}
|
|
34
50
|
},
|
|
35
51
|
"scripts": {
|
|
36
52
|
"typecheck": "tsc --noEmit",
|
|
37
53
|
"lint": "biome check",
|
|
38
54
|
"format": "biome check --write",
|
|
55
|
+
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
39
56
|
"changeset": "changeset",
|
|
40
57
|
"version": "changeset version",
|
|
41
58
|
"release": "pnpm changeset publish"
|
package/src/commands/quotas.ts
CHANGED
|
@@ -3,22 +3,12 @@ import type { Component } from "@mariozechner/pi-tui";
|
|
|
3
3
|
import { QuotasDisplayComponent } from "../components/quotas-display";
|
|
4
4
|
import { QuotasErrorComponent } from "../components/quotas-error";
|
|
5
5
|
import { QuotasLoadingComponent } from "../components/quotas-loading";
|
|
6
|
-
import
|
|
6
|
+
import { fetchQuotas } from "../utils/quotas";
|
|
7
7
|
|
|
8
8
|
export function registerQuotasCommand(pi: ExtensionAPI): void {
|
|
9
9
|
pi.registerCommand("synthetic:quotas", {
|
|
10
10
|
description: "Display Synthetic API usage quotas",
|
|
11
11
|
handler: async (_args, ctx) => {
|
|
12
|
-
if (!ctx.hasUI) {
|
|
13
|
-
const quotas = await fetchQuotas();
|
|
14
|
-
if (!quotas) {
|
|
15
|
-
console.error("Failed to fetch quotas");
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
console.log(formatQuotasPlain(quotas));
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
12
|
const result = await ctx.ui.custom<null>((tui, theme, _kb, done) => {
|
|
23
13
|
let currentComponent: Component = new QuotasLoadingComponent(theme);
|
|
24
14
|
|
|
@@ -30,7 +20,13 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
|
|
|
30
20
|
"Failed to fetch quotas",
|
|
31
21
|
);
|
|
32
22
|
} else {
|
|
33
|
-
currentComponent = new QuotasDisplayComponent(
|
|
23
|
+
currentComponent = new QuotasDisplayComponent(
|
|
24
|
+
theme,
|
|
25
|
+
quotas,
|
|
26
|
+
() => {
|
|
27
|
+
done(null);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
34
30
|
}
|
|
35
31
|
tui.requestRender();
|
|
36
32
|
})
|
|
@@ -45,90 +41,28 @@ export function registerQuotasCommand(pi: ExtensionAPI): void {
|
|
|
45
41
|
return {
|
|
46
42
|
render: (width: number) => currentComponent.render(width),
|
|
47
43
|
invalidate: () => currentComponent.invalidate(),
|
|
48
|
-
handleInput: (
|
|
44
|
+
handleInput: (data: string) => {
|
|
45
|
+
if (currentComponent.handleInput) {
|
|
46
|
+
return currentComponent.handleInput(data);
|
|
47
|
+
}
|
|
49
48
|
done(null);
|
|
49
|
+
return true;
|
|
50
50
|
},
|
|
51
51
|
};
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
// RPC fallback:
|
|
54
|
+
// RPC fallback: return JSON
|
|
55
55
|
if (result === undefined) {
|
|
56
56
|
const quotas = await fetchQuotas();
|
|
57
57
|
if (!quotas) {
|
|
58
|
-
ctx.ui.notify(
|
|
58
|
+
ctx.ui.notify(
|
|
59
|
+
JSON.stringify({ error: "Failed to fetch quotas" }),
|
|
60
|
+
"error",
|
|
61
|
+
);
|
|
59
62
|
return;
|
|
60
63
|
}
|
|
61
|
-
ctx.ui.notify(
|
|
64
|
+
ctx.ui.notify(JSON.stringify(quotas, null, 2), "info");
|
|
62
65
|
}
|
|
63
66
|
},
|
|
64
67
|
});
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
async function fetchQuotas(): Promise<QuotasResponse | null> {
|
|
68
|
-
const apiKey = process.env.SYNTHETIC_API_KEY;
|
|
69
|
-
if (!apiKey) {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const response = await fetch("https://api.synthetic.new/v2/quotas", {
|
|
75
|
-
headers: {
|
|
76
|
-
Authorization: `Bearer ${apiKey}`,
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
if (!response.ok) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return (await response.json()) as QuotasResponse;
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function formatQuotasPlain(quotas: QuotasResponse): string {
|
|
91
|
-
const remaining = quotas.subscription.limit - quotas.subscription.requests;
|
|
92
|
-
const percentUsed = Math.round(
|
|
93
|
-
(quotas.subscription.requests / quotas.subscription.limit) * 100,
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
return [
|
|
97
|
-
"Synthetic API Quotas",
|
|
98
|
-
"",
|
|
99
|
-
`Usage: ${percentUsed}%`,
|
|
100
|
-
`Limit: ${quotas.subscription.limit.toLocaleString()} requests`,
|
|
101
|
-
`Used: ${quotas.subscription.requests.toLocaleString()} requests`,
|
|
102
|
-
`Remaining: ${remaining.toLocaleString()} requests`,
|
|
103
|
-
"",
|
|
104
|
-
`Renews: ${quotas.subscription.renewsAt} (${formatRelativeTime(new Date(quotas.subscription.renewsAt))})`,
|
|
105
|
-
].join("\n");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function formatRelativeTime(date: Date): string {
|
|
109
|
-
const now = new Date();
|
|
110
|
-
const diffMs = date.getTime() - now.getTime();
|
|
111
|
-
|
|
112
|
-
if (diffMs <= 0) {
|
|
113
|
-
return "renews soon";
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
117
|
-
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
|
|
118
|
-
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
|
119
|
-
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
120
|
-
|
|
121
|
-
if (diffMinutes < 60) {
|
|
122
|
-
return rtf.format(diffMinutes, "minute");
|
|
123
|
-
} else if (diffHours < 24) {
|
|
124
|
-
return rtf.format(diffHours, "hour");
|
|
125
|
-
} else if (diffDays < 30) {
|
|
126
|
-
return rtf.format(diffDays, "day");
|
|
127
|
-
} else if (diffDays < 365) {
|
|
128
|
-
const months = Math.floor(diffDays / 30);
|
|
129
|
-
return rtf.format(months, "month");
|
|
130
|
-
} else {
|
|
131
|
-
const years = Math.floor(diffDays / 365);
|
|
132
|
-
return rtf.format(years, "year");
|
|
133
|
-
}
|
|
134
|
-
}
|
|
@@ -1,139 +1,240 @@
|
|
|
1
1
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { Component, TUI } from "@mariozechner/pi-tui";
|
|
3
|
+
import { matchesKey } from "@mariozechner/pi-tui";
|
|
4
4
|
import type { QuotasResponse } from "../types/quotas";
|
|
5
|
+
import { TabbedScrollablePanel } from "./tabbed-panel";
|
|
5
6
|
|
|
6
7
|
export class QuotasDisplayComponent implements Component {
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
8
|
+
private panel: TabbedScrollablePanel;
|
|
9
|
+
private onClose: () => void;
|
|
10
|
+
|
|
11
|
+
constructor(theme: Theme, quotas: QuotasResponse, onClose: () => void) {
|
|
12
|
+
this.onClose = onClose;
|
|
13
|
+
|
|
14
|
+
this.panel = new TabbedScrollablePanel(
|
|
15
|
+
{
|
|
16
|
+
title: "Synthetic API Quotas",
|
|
17
|
+
tabs: [
|
|
18
|
+
{
|
|
19
|
+
label: "Completions",
|
|
20
|
+
buildContent: () =>
|
|
21
|
+
buildHybridLayout(theme, quotas.subscription, 5), // 5-hour window
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: "Search",
|
|
25
|
+
buildContent: () =>
|
|
26
|
+
buildHybridLayout(theme, quotas.search.hourly, 1), // 1 hour
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
label: "Free tool call",
|
|
30
|
+
buildContent: () =>
|
|
31
|
+
buildToolCallsLayout(theme, quotas.freeToolCalls, 24), // 24 hours (daily)
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
onClose: onClose,
|
|
35
|
+
},
|
|
36
|
+
null as unknown as TUI,
|
|
37
|
+
theme,
|
|
22
38
|
);
|
|
39
|
+
}
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
let bar: string;
|
|
30
|
-
if (usedWidth >= barWidth) {
|
|
31
|
-
bar = theme.fg("error", "█".repeat(barWidth));
|
|
32
|
-
} else if (percentUsed > 75) {
|
|
33
|
-
// High usage: used is warning, remaining is dim
|
|
34
|
-
bar =
|
|
35
|
-
theme.fg("warning", "█".repeat(usedWidth)) +
|
|
36
|
-
theme.fg("dim", "█".repeat(remainingWidth));
|
|
37
|
-
} else {
|
|
38
|
-
// Normal usage: used is success, remaining is dim
|
|
39
|
-
bar =
|
|
40
|
-
theme.fg("success", "█".repeat(usedWidth)) +
|
|
41
|
-
theme.fg("dim", "█".repeat(remainingWidth));
|
|
41
|
+
handleInput(data: string): boolean {
|
|
42
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
43
|
+
this.onClose();
|
|
44
|
+
return true;
|
|
42
45
|
}
|
|
46
|
+
return this.panel.handleInput(data);
|
|
47
|
+
}
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
this.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Numbers - aligned columns
|
|
49
|
-
const limitStr = quotas.subscription.limit.toLocaleString();
|
|
50
|
-
const usedStr = quotas.subscription.requests.toLocaleString();
|
|
51
|
-
const remainingStr = remaining.toLocaleString();
|
|
52
|
-
const maxValueWidth = Math.max(
|
|
53
|
-
limitStr.length,
|
|
54
|
-
usedStr.length,
|
|
55
|
-
remainingStr.length,
|
|
56
|
-
);
|
|
49
|
+
render(width: number): string[] {
|
|
50
|
+
return this.panel.render(width);
|
|
51
|
+
}
|
|
57
52
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
0,
|
|
63
|
-
),
|
|
64
|
-
);
|
|
65
|
-
this.container.addChild(
|
|
66
|
-
new Text(
|
|
67
|
-
` ${theme.fg("muted", "Used:")} ${usedStr.padStart(maxValueWidth, " ")} requests`,
|
|
68
|
-
1,
|
|
69
|
-
0,
|
|
70
|
-
),
|
|
71
|
-
);
|
|
72
|
-
this.container.addChild(
|
|
73
|
-
new Text(
|
|
74
|
-
` ${theme.fg("muted", "Remaining:")} ${theme.fg(
|
|
75
|
-
remaining > 0 ? "success" : "error",
|
|
76
|
-
remainingStr.padStart(maxValueWidth, " "),
|
|
77
|
-
)} requests`,
|
|
78
|
-
1,
|
|
79
|
-
0,
|
|
80
|
-
),
|
|
81
|
-
);
|
|
82
|
-
this.container.addChild(new Text("", 0, 0));
|
|
83
|
-
|
|
84
|
-
// Renewal date - ISO8601 with relative time
|
|
85
|
-
const renewsAt = new Date(quotas.subscription.renewsAt);
|
|
86
|
-
const isoStr = quotas.subscription.renewsAt;
|
|
87
|
-
const relativeStr = formatRelativeTime(renewsAt);
|
|
88
|
-
|
|
89
|
-
this.container.addChild(
|
|
90
|
-
new Text(
|
|
91
|
-
` ${theme.fg("muted", "Renews:")} ${isoStr} (${relativeStr})`,
|
|
92
|
-
1,
|
|
93
|
-
0,
|
|
94
|
-
),
|
|
95
|
-
);
|
|
53
|
+
invalidate(): void {
|
|
54
|
+
this.panel.invalidate();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
96
57
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
58
|
+
// Layout: bar + pct/cur-total + pace/reset
|
|
59
|
+
function buildHybridLayout(
|
|
60
|
+
theme: Theme,
|
|
61
|
+
quota: { limit: number; requests: number; renewsAt: string },
|
|
62
|
+
periodHours: number,
|
|
63
|
+
): string[] {
|
|
64
|
+
const percentUsed = Math.round((quota.requests / quota.limit) * 100);
|
|
65
|
+
const renewsAt = new Date(quota.renewsAt);
|
|
66
|
+
const now = new Date();
|
|
67
|
+
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
const barWidth = 50;
|
|
70
|
+
|
|
71
|
+
const usedStr = quota.requests.toLocaleString();
|
|
72
|
+
const limitStr = quota.limit.toLocaleString();
|
|
73
|
+
|
|
74
|
+
// Color based on usage
|
|
75
|
+
const usedColor =
|
|
76
|
+
percentUsed >= 100 ? "error" : percentUsed > 75 ? "warning" : "success";
|
|
77
|
+
|
|
78
|
+
// Calculate pace with known period
|
|
79
|
+
const totalPeriod = periodHours * 60 * 60 * 1000;
|
|
80
|
+
const timeUntilReset = Math.max(0, renewsAt.getTime() - now.getTime());
|
|
81
|
+
const timeElapsed = totalPeriod - timeUntilReset;
|
|
82
|
+
const percentTimeElapsed = (timeElapsed / totalPeriod) * 100;
|
|
83
|
+
const paceDiff = percentUsed - percentTimeElapsed;
|
|
84
|
+
|
|
85
|
+
let paceStr: string;
|
|
86
|
+
let paceColor: "success" | "dim" | "error";
|
|
87
|
+
if (paceDiff < -10) {
|
|
88
|
+
paceStr = `${Math.round(Math.abs(paceDiff))}% behind pace`;
|
|
89
|
+
paceColor = "success";
|
|
90
|
+
} else if (paceDiff > 10) {
|
|
91
|
+
paceStr = `${Math.round(paceDiff)}% ahead of pace`;
|
|
92
|
+
paceColor = "error";
|
|
93
|
+
} else {
|
|
94
|
+
paceStr = "within pace";
|
|
95
|
+
paceColor = "dim";
|
|
102
96
|
}
|
|
103
97
|
|
|
104
|
-
|
|
105
|
-
|
|
98
|
+
// Row above bar: pct left, cur/total right
|
|
99
|
+
const pctStr = `${percentUsed}% used`;
|
|
100
|
+
const totalDisplay = `${usedStr}/${limitStr}`;
|
|
101
|
+
const spacing = " ".repeat(
|
|
102
|
+
Math.max(1, barWidth - pctStr.length - totalDisplay.length),
|
|
103
|
+
);
|
|
104
|
+
lines.push(
|
|
105
|
+
` ${theme.fg(usedColor, pctStr)}${spacing}${theme.fg("dim", totalDisplay)}`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Bar
|
|
109
|
+
const usedWidth = Math.round((percentUsed / 100) * barWidth);
|
|
110
|
+
let bar: string;
|
|
111
|
+
if (usedWidth >= barWidth) {
|
|
112
|
+
bar = theme.fg("error", "█".repeat(barWidth));
|
|
113
|
+
} else if (percentUsed > 75) {
|
|
114
|
+
bar =
|
|
115
|
+
theme.fg("warning", "█".repeat(usedWidth)) +
|
|
116
|
+
theme.fg("dim", "█".repeat(barWidth - usedWidth));
|
|
117
|
+
} else {
|
|
118
|
+
bar =
|
|
119
|
+
theme.fg("success", "█".repeat(usedWidth)) +
|
|
120
|
+
theme.fg("dim", "█".repeat(barWidth - usedWidth));
|
|
106
121
|
}
|
|
122
|
+
lines.push(` ${bar}`);
|
|
123
|
+
|
|
124
|
+
// Row below bar: pace left, reset right
|
|
125
|
+
const resetStr = formatShortTime(renewsAt);
|
|
126
|
+
const paceSpacing = " ".repeat(
|
|
127
|
+
Math.max(1, barWidth - paceStr.length - resetStr.length),
|
|
128
|
+
);
|
|
129
|
+
lines.push(
|
|
130
|
+
` ${theme.fg(paceColor, paceStr)}${paceSpacing}${theme.fg("dim", resetStr)}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return lines;
|
|
134
|
+
}
|
|
107
135
|
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
// Layout for free tool calls - shows remaining, not usage
|
|
137
|
+
function buildToolCallsLayout(
|
|
138
|
+
theme: Theme,
|
|
139
|
+
quota: { limit: number; requests: number; renewsAt: string },
|
|
140
|
+
periodHours: number,
|
|
141
|
+
): string[] {
|
|
142
|
+
const remaining = quota.limit - quota.requests;
|
|
143
|
+
const percentRemaining = Math.round((remaining / quota.limit) * 100);
|
|
144
|
+
const renewsAt = new Date(quota.renewsAt);
|
|
145
|
+
const now = new Date();
|
|
146
|
+
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
const barWidth = 50;
|
|
149
|
+
|
|
150
|
+
const remainingStr = remaining.toLocaleString();
|
|
151
|
+
|
|
152
|
+
// Color based on remaining (inverse of usage)
|
|
153
|
+
const remainingColor =
|
|
154
|
+
remaining <= 0 ? "error" : percentRemaining < 25 ? "warning" : "success";
|
|
155
|
+
|
|
156
|
+
// Calculate pace (how fast you're consuming free calls)
|
|
157
|
+
const totalPeriod = periodHours * 60 * 60 * 1000;
|
|
158
|
+
const timeUntilReset = Math.max(0, renewsAt.getTime() - now.getTime());
|
|
159
|
+
const timeElapsed = totalPeriod - timeUntilReset;
|
|
160
|
+
const percentTimeElapsed = (timeElapsed / totalPeriod) * 100;
|
|
161
|
+
const expectedRemaining = Math.round(
|
|
162
|
+
quota.limit * (1 - percentTimeElapsed / 100),
|
|
163
|
+
);
|
|
164
|
+
const remainingDiff = remaining - expectedRemaining;
|
|
165
|
+
|
|
166
|
+
let paceStr: string;
|
|
167
|
+
let paceColor: "success" | "dim" | "error";
|
|
168
|
+
if (remainingDiff > quota.limit * 0.1) {
|
|
169
|
+
paceStr = `${remainingDiff.toLocaleString()} more than expected`;
|
|
170
|
+
paceColor = "success";
|
|
171
|
+
} else if (remainingDiff < -quota.limit * 0.1) {
|
|
172
|
+
paceStr = `${Math.abs(remainingDiff).toLocaleString()} fewer than expected`;
|
|
173
|
+
paceColor = "error";
|
|
174
|
+
} else {
|
|
175
|
+
paceStr = "on track";
|
|
176
|
+
paceColor = "dim";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Row above bar: pct remaining left, ratio right (like other tabs)
|
|
180
|
+
const pctStr = `${percentRemaining}% remaining`;
|
|
181
|
+
const ratioStr = `${remainingStr}/${quota.limit.toLocaleString()}`;
|
|
182
|
+
const spacing = " ".repeat(
|
|
183
|
+
Math.max(1, barWidth - pctStr.length - ratioStr.length),
|
|
184
|
+
);
|
|
185
|
+
lines.push(
|
|
186
|
+
` ${theme.fg(remainingColor, pctStr)}${spacing}${theme.fg("dim", ratioStr)}`,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Bar (shows remaining, not used - so full bar = all remaining)
|
|
190
|
+
const remainingWidth = Math.round((percentRemaining / 100) * barWidth);
|
|
191
|
+
let bar: string;
|
|
192
|
+
if (remaining <= 0) {
|
|
193
|
+
bar = theme.fg("dim", "█".repeat(barWidth));
|
|
194
|
+
} else if (percentRemaining < 25) {
|
|
195
|
+
bar =
|
|
196
|
+
theme.fg("warning", "█".repeat(remainingWidth)) +
|
|
197
|
+
theme.fg("dim", "█".repeat(barWidth - remainingWidth));
|
|
198
|
+
} else {
|
|
199
|
+
bar =
|
|
200
|
+
theme.fg("success", "█".repeat(remainingWidth)) +
|
|
201
|
+
theme.fg("dim", "█".repeat(barWidth - remainingWidth));
|
|
110
202
|
}
|
|
203
|
+
lines.push(` ${bar}`);
|
|
204
|
+
|
|
205
|
+
// Row below bar: pace left, reset right
|
|
206
|
+
const resetStr = formatShortTime(renewsAt);
|
|
207
|
+
const paceSpacing = " ".repeat(
|
|
208
|
+
Math.max(1, barWidth - paceStr.length - resetStr.length),
|
|
209
|
+
);
|
|
210
|
+
lines.push(
|
|
211
|
+
` ${theme.fg(paceColor, paceStr)}${paceSpacing}${theme.fg("dim", resetStr)}`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// If depleted, show note about paid calls
|
|
215
|
+
if (remaining <= 0) {
|
|
216
|
+
lines.push(` ${theme.fg("dim", "Additional calls will be charged")}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return lines;
|
|
111
220
|
}
|
|
112
221
|
|
|
113
|
-
function
|
|
222
|
+
function formatShortTime(date: Date): string {
|
|
114
223
|
const now = new Date();
|
|
115
224
|
const diffMs = date.getTime() - now.getTime();
|
|
116
225
|
|
|
117
226
|
if (diffMs <= 0) {
|
|
118
|
-
return "
|
|
227
|
+
return "soon";
|
|
119
228
|
}
|
|
120
229
|
|
|
121
|
-
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
122
|
-
const diffMinutes = Math.ceil(diffMs / (1000 * 60));
|
|
123
230
|
const diffHours = Math.ceil(diffMs / (1000 * 60 * 60));
|
|
124
231
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
125
232
|
|
|
126
|
-
if (
|
|
127
|
-
return
|
|
128
|
-
} else if (
|
|
129
|
-
return
|
|
130
|
-
} else if (diffDays < 30) {
|
|
131
|
-
return rtf.format(diffDays, "day");
|
|
132
|
-
} else if (diffDays < 365) {
|
|
133
|
-
const months = Math.floor(diffDays / 30);
|
|
134
|
-
return rtf.format(months, "month");
|
|
233
|
+
if (diffHours < 24) {
|
|
234
|
+
return `in ${diffHours}h`;
|
|
235
|
+
} else if (diffDays < 7) {
|
|
236
|
+
return `in ${diffDays}d`;
|
|
135
237
|
} else {
|
|
136
|
-
|
|
137
|
-
return rtf.format(years, "year");
|
|
238
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
138
239
|
}
|
|
139
240
|
}
|