@cad0p/pi-usage 0.0.0-stage → 0.11.2
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/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- package/src/sticky.js +319 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared renderers: a compact single line for status bars and a boxed panel for
|
|
3
|
+
* terminals and TUI widgets.
|
|
4
|
+
*
|
|
5
|
+
* Both take a `Paint` function so the terminal can use ANSI while the Pi TUI
|
|
6
|
+
* can paint through its own theme.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { bar, percentText, ringGlyph, thresholdKey } from "./theme.js";
|
|
10
|
+
import { errorCaption, errorKind } from "../providers/error-kind.js";
|
|
11
|
+
import { humanDuration, humanReset, selectPrimaryWindow } from "../model.js";
|
|
12
|
+
|
|
13
|
+
const FAMILY_SHORT = {
|
|
14
|
+
claude: "C",
|
|
15
|
+
codex: "X",
|
|
16
|
+
antigravity: "A",
|
|
17
|
+
"opencode-go": "G",
|
|
18
|
+
"github-actions": "GH",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const FAMILY_TITLE = {
|
|
22
|
+
claude: "Claude (Pi)",
|
|
23
|
+
codex: "Codex (Pi)",
|
|
24
|
+
antigravity: "Antigravity (Pi)",
|
|
25
|
+
"opencode-go": "OpenCode Go (Pi)",
|
|
26
|
+
"github-actions": "GitHub Actions",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const MIN_WIDTH = 52;
|
|
30
|
+
const MAX_WIDTH = 104;
|
|
31
|
+
const NOTE_MAX = 44;
|
|
32
|
+
/** Visible width reserved by `│ ` + ` │` on box content lines. */
|
|
33
|
+
const BOX_PADDING = 4;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The window that represents a provider at a glance: the shortest one, so a
|
|
37
|
+
* status line answers "can I keep working right now".
|
|
38
|
+
*
|
|
39
|
+
* @param {import("../model.js").QuotaResult} provider
|
|
40
|
+
* @returns {import("../model.js").QuotaWindow | null}
|
|
41
|
+
*/
|
|
42
|
+
export function headlineWindow(provider) {
|
|
43
|
+
const byId = (provider.windows ?? []).find((window) => window.id === provider.primaryWindowId);
|
|
44
|
+
return byId ?? selectPrimaryWindow(provider.windows ?? []);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {string} kind @returns {string} */
|
|
48
|
+
function statusGlyphForKind(kind) {
|
|
49
|
+
switch (kind) {
|
|
50
|
+
case "transient":
|
|
51
|
+
return "~";
|
|
52
|
+
case "throttle":
|
|
53
|
+
return "…";
|
|
54
|
+
case "expired":
|
|
55
|
+
case "auth":
|
|
56
|
+
return "!";
|
|
57
|
+
case "missing":
|
|
58
|
+
return "·";
|
|
59
|
+
default:
|
|
60
|
+
return "?";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {string} kind @returns {string} */
|
|
65
|
+
function statusPaintKeyForKind(kind) {
|
|
66
|
+
if (kind === "transient" || kind === "throttle") return "warn";
|
|
67
|
+
if (kind === "missing") return "dim";
|
|
68
|
+
if (kind === "unknown") return "unknown";
|
|
69
|
+
return "danger";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Keep the first result per family.
|
|
74
|
+
*
|
|
75
|
+
* The compact surfaces answer "how much have I burned" at a glance; a family
|
|
76
|
+
* with fourteen accounts would otherwise repeat its glyph fourteen times. The
|
|
77
|
+
* full panel is the one place that lists every account.
|
|
78
|
+
*
|
|
79
|
+
* @param {import("../model.js").QuotaResult[]} providers
|
|
80
|
+
* @returns {import("../model.js").QuotaResult[]}
|
|
81
|
+
*/
|
|
82
|
+
export function firstPerFamily(providers) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
/** @type {import("../model.js").QuotaResult[]} */
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const provider of providers ?? []) {
|
|
87
|
+
if (!provider || seen.has(provider.family)) continue;
|
|
88
|
+
seen.add(provider.family);
|
|
89
|
+
out.push(provider);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compact status-line text, e.g. `C ● 78% · X ◑ 44% · A ○ 8% · G ! n/a`.
|
|
96
|
+
*
|
|
97
|
+
* @param {import("../model.js").QuotaResult[]} providers
|
|
98
|
+
* @param {import("./theme.js").Paint} paint
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function renderStatusLine(providers, paint) {
|
|
102
|
+
const active = firstPerFamily(providers).filter((provider) => !provider.notConfigured);
|
|
103
|
+
if (active.length === 0) return paint("dim", "quota: no configured providers");
|
|
104
|
+
|
|
105
|
+
const parts = [];
|
|
106
|
+
for (const provider of active) {
|
|
107
|
+
const short = FAMILY_SHORT[provider.family] ?? provider.family.slice(0, 1).toUpperCase();
|
|
108
|
+
const window = headlineWindow(provider);
|
|
109
|
+
const remaining = window?.remainingPercent ?? null;
|
|
110
|
+
|
|
111
|
+
if (!provider.ok) {
|
|
112
|
+
const kind = errorKind(provider.error);
|
|
113
|
+
parts.push(paint(statusPaintKeyForKind(kind), `${short} ${statusGlyphForKind(kind)} ${errorCaption(provider.error)}`));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (remaining === null) {
|
|
118
|
+
parts.push(paint("unknown", `${short} ? n/a`));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
parts.push(paint(thresholdKey(remaining), `${short} ${ringGlyph(remaining)} ${`${Math.round(remaining)}%`.padStart(4)}`));
|
|
123
|
+
}
|
|
124
|
+
return parts.join(paint("dim", " · "));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Truncate plain text to a visible width, appending an ellipsis when cut.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} text
|
|
131
|
+
* @param {number} width
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function clip(text, width) {
|
|
135
|
+
if (text.length <= width) return text;
|
|
136
|
+
return text.slice(0, Math.max(0, width - 1)).trimEnd() + "…";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wrap plain text on word boundaries.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} text
|
|
143
|
+
* @param {number} width
|
|
144
|
+
* @returns {string[]}
|
|
145
|
+
*/
|
|
146
|
+
function wrapPlain(text, width) {
|
|
147
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
148
|
+
/** @type {string[]} */
|
|
149
|
+
const lines = [];
|
|
150
|
+
let current = "";
|
|
151
|
+
for (const word of words) {
|
|
152
|
+
const candidate = current === "" ? word : `${current} ${word}`;
|
|
153
|
+
if (candidate.length <= width) {
|
|
154
|
+
current = candidate;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (current !== "") lines.push(current);
|
|
158
|
+
current = word.length > width ? clip(word, width) : word;
|
|
159
|
+
}
|
|
160
|
+
if (current !== "") lines.push(current);
|
|
161
|
+
return lines.length > 0 ? lines : [""];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Per-provider detail lines, without box decoration.
|
|
166
|
+
*
|
|
167
|
+
* @param {import("../model.js").QuotaResult} provider
|
|
168
|
+
* @param {import("./theme.js").Paint} paint
|
|
169
|
+
* @param {number} contentWidth
|
|
170
|
+
* @param {{ title?: string | null, accountInTitle?: boolean }} [options]
|
|
171
|
+
* @returns {string[]}
|
|
172
|
+
*/
|
|
173
|
+
export function renderProviderLines(provider, paint, contentWidth = MAX_WIDTH, options = {}) {
|
|
174
|
+
const title = options.title ?? FAMILY_TITLE[provider.family] ?? provider.label;
|
|
175
|
+
/** @type {string[]} */
|
|
176
|
+
const lines = [];
|
|
177
|
+
|
|
178
|
+
// A multi-account family puts "#<slot>" in the title, so repeating it in the
|
|
179
|
+
// meta line would print the same label twice on one row.
|
|
180
|
+
const meta = options.accountInTitle ? [] : [provider.account];
|
|
181
|
+
if (provider.plan) meta.push(`plan ${provider.plan}`);
|
|
182
|
+
// Claude has two stores, and a broken one is the first thing to rule out when
|
|
183
|
+
// the percentage looks wrong, so the panel names the store it actually read.
|
|
184
|
+
if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
|
|
185
|
+
if (provider.expiresInMin !== null && provider.expiresInMin <= 120) {
|
|
186
|
+
meta.push(provider.expiresInMin < 0 ? "token EXPIRED" : `token ${provider.expiresInMin}m`);
|
|
187
|
+
}
|
|
188
|
+
lines.push(clip(`${paint("bold", title)} ${paint("dim", `· ${meta.join(" · ")}`)}`, contentWidth));
|
|
189
|
+
|
|
190
|
+
if (!provider.ok) {
|
|
191
|
+
const wrapped = wrapPlain(provider.error ?? "unavailable", contentWidth - 6);
|
|
192
|
+
lines.push(` ${paint("danger", "!")} ${paint("warning", wrapped[0])}`);
|
|
193
|
+
for (const extra of wrapped.slice(1)) lines.push(` ${paint("warning", extra)}`);
|
|
194
|
+
for (const window of provider.windows ?? []) {
|
|
195
|
+
lines.push(` ${paint("dim", window.label)} ${renderWindow(window, paint, contentWidth)}`);
|
|
196
|
+
}
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if ((provider.windows ?? []).length === 0) {
|
|
201
|
+
lines.push(` ${paint("dim", "no rate-limit windows reported")}`);
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const labelWidth = Math.max(...provider.windows.map((window) => window.label.length));
|
|
206
|
+
for (const window of provider.windows) {
|
|
207
|
+
lines.push(` ${paint("dim", window.label.padEnd(labelWidth))} ${renderWindow(window, paint, contentWidth)}`);
|
|
208
|
+
}
|
|
209
|
+
return lines;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* @param {import("../model.js").QuotaWindow} window
|
|
214
|
+
* @param {import("./theme.js").Paint} paint
|
|
215
|
+
* @param {number} contentWidth
|
|
216
|
+
* @returns {string}
|
|
217
|
+
*/
|
|
218
|
+
function renderWindow(window, paint, contentWidth) {
|
|
219
|
+
if (window.remainingPercent === null) {
|
|
220
|
+
return paint("dim", clip(window.note ?? "no percentage reported", NOTE_MAX));
|
|
221
|
+
}
|
|
222
|
+
const key = thresholdKey(window.remainingPercent);
|
|
223
|
+
const parts = [
|
|
224
|
+
paint(key, ringGlyph(window.remainingPercent)),
|
|
225
|
+
paint(key, bar(window.remainingPercent)),
|
|
226
|
+
paint(key, percentText(window.remainingPercent)),
|
|
227
|
+
paint("dim", "left"),
|
|
228
|
+
];
|
|
229
|
+
if (window.resetsInSec !== null) {
|
|
230
|
+
parts.push(paint("dim", `· ${humanReset(window.resetsInSec)}`));
|
|
231
|
+
}
|
|
232
|
+
if (window.note) {
|
|
233
|
+
parts.push(paint("dim", `· ${clip(window.note, NOTE_MAX)}`));
|
|
234
|
+
}
|
|
235
|
+
return clip(parts.join(" "), contentWidth);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Compact five-row quota widget, aligned against the terminal's right edge.
|
|
240
|
+
*
|
|
241
|
+
* @param {import("../model.js").QuotaResult[]} providers
|
|
242
|
+
* @param {import("./theme.js").Paint} paint
|
|
243
|
+
* @param {number} [totalWidth]
|
|
244
|
+
* @returns {string[]}
|
|
245
|
+
*/
|
|
246
|
+
export function renderBoxWidget(providers, paint, totalWidth = typeof process?.stdout?.columns === "number" ? process.stdout.columns : 80) {
|
|
247
|
+
const isFiveHour = (window) =>
|
|
248
|
+
window?.id === "5h" || /5h|session|hour/i.test(`${window?.id ?? ""} ${window?.label ?? ""}`) ||
|
|
249
|
+
(typeof window?.windowSeconds === "number" && window.windowSeconds <= 6 * 3600);
|
|
250
|
+
const providerFor = (family) => providers.find((provider) => provider.family === family);
|
|
251
|
+
const windowFor = (family, match = isFiveHour) => {
|
|
252
|
+
const provider = providerFor(family);
|
|
253
|
+
return { provider, window: provider?.windows?.find(match) ?? null };
|
|
254
|
+
};
|
|
255
|
+
const usedOf = (win) => {
|
|
256
|
+
if (!win) return null;
|
|
257
|
+
if (typeof win.usedPercent === "number" && Number.isFinite(win.usedPercent)) return win.usedPercent;
|
|
258
|
+
if (typeof win.remainingPercent === "number" && Number.isFinite(win.remainingPercent)) return 100 - win.remainingPercent;
|
|
259
|
+
return null;
|
|
260
|
+
};
|
|
261
|
+
const rows = [
|
|
262
|
+
{ label: "Claude: ", ...windowFor("claude") },
|
|
263
|
+
{ label: "Codex: ", ...windowFor("codex") },
|
|
264
|
+
{ label: "Agy: ", ...windowFor("antigravity", (window) => window.id === "gemini-5h" || (/gemini/i.test(window.id) && isFiveHour(window))) },
|
|
265
|
+
{ label: "Agy C/G:", ...windowFor("antigravity", (window) => window.id === "claude-gpt-5h" || (/(claude|gpt|3p)/i.test(window.id) && isFiveHour(window))) },
|
|
266
|
+
{ label: "OP-Go: ", ...windowFor("opencode-go") },
|
|
267
|
+
];
|
|
268
|
+
if (providerFor("github-actions")) {
|
|
269
|
+
rows.push({ label: "GH Actions:", ...windowFor("github-actions", (window) => window.id === "monthly") });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const content = rows.map(({ label, provider, window }) => {
|
|
273
|
+
const name = paint("bold", label);
|
|
274
|
+
if (!provider || !provider.ok) {
|
|
275
|
+
const error = provider ? errorCaption(provider.error) : "not configured";
|
|
276
|
+
return { left: `${name} ${paint("danger", `! ${error}`)}`, right: "" };
|
|
277
|
+
}
|
|
278
|
+
const used = usedOf(window);
|
|
279
|
+
let mid = paint("unknown", "? n/a");
|
|
280
|
+
if (used !== null) {
|
|
281
|
+
const rem = Math.max(0, 100 - used);
|
|
282
|
+
const key = thresholdKey(rem);
|
|
283
|
+
mid = `${paint(key, ringGlyph(rem))} ${paint(key, `${Math.round(used)}%`.padStart(4))}`;
|
|
284
|
+
}
|
|
285
|
+
const effectiveResetsInSec =
|
|
286
|
+
window?.resetsInSec ??
|
|
287
|
+
(used === 0 && typeof window?.windowSeconds === "number" && window.windowSeconds > 0
|
|
288
|
+
? window.windowSeconds
|
|
289
|
+
: null);
|
|
290
|
+
return {
|
|
291
|
+
left: `${name} ${mid}`,
|
|
292
|
+
right: paint("dim", `R:${humanDuration(effectiveResetsInSec)}`),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
const innerWidth = Math.max(25, ...content.map(({ left, right }) => visibleLength(left) + (right ? 2 + visibleLength(right) : 0)));
|
|
296
|
+
const box = [
|
|
297
|
+
paint("accent", `╭─ Quota ${"─".repeat(Math.max(0, innerWidth - 6))}╮`),
|
|
298
|
+
...content.map(({ left, right }) => {
|
|
299
|
+
const padding = right
|
|
300
|
+
? " ".repeat(Math.max(1, innerWidth - visibleLength(left) - visibleLength(right)))
|
|
301
|
+
: " ".repeat(Math.max(0, innerWidth - visibleLength(left)));
|
|
302
|
+
return `${paint("accent", "│")} ${left}${padding}${right} ${paint("accent", "│")}`;
|
|
303
|
+
}),
|
|
304
|
+
paint("accent", `╰${"─".repeat(innerWidth + 2)}╯`),
|
|
305
|
+
];
|
|
306
|
+
return box.map((line) => `${" ".repeat(Math.max(0, totalWidth - visibleLength(line)))}${line}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function renderPanel(providers, paint, meta = {}) {
|
|
310
|
+
const terminalWidth = typeof process?.stdout?.columns === "number" ? process.stdout.columns : MAX_WIDTH;
|
|
311
|
+
const hardMax = Math.max(MIN_WIDTH, Math.min(meta.maxWidth ?? MAX_WIDTH, terminalWidth - 2, MAX_WIDTH));
|
|
312
|
+
|
|
313
|
+
// A family with several accounts gets one row per account, and the row title
|
|
314
|
+
// carries the account label ("OpenCode Go (Pi) #2") so the list stays scannable.
|
|
315
|
+
/** @type {Map<string, number>} */
|
|
316
|
+
const familyCounts = new Map();
|
|
317
|
+
for (const provider of providers ?? []) {
|
|
318
|
+
familyCounts.set(provider.family, (familyCounts.get(provider.family) ?? 0) + 1);
|
|
319
|
+
}
|
|
320
|
+
/** @param {import("../model.js").QuotaResult} provider */
|
|
321
|
+
const accountTitle = (provider) =>
|
|
322
|
+
(familyCounts.get(provider.family) ?? 0) > 1 && typeof provider.account === "string" && provider.account.startsWith("#")
|
|
323
|
+
? `${FAMILY_TITLE[provider.family] ?? provider.label} ${provider.account}`
|
|
324
|
+
: null;
|
|
325
|
+
|
|
326
|
+
// Render once at the hard maximum, then settle on the natural width of the
|
|
327
|
+
// content so long degradation messages cannot stretch every other row.
|
|
328
|
+
const firstPass = providers.flatMap((provider) =>
|
|
329
|
+
renderProviderLines(provider, paint, hardMax - BOX_PADDING, {
|
|
330
|
+
title: accountTitle(provider),
|
|
331
|
+
accountInTitle: accountTitle(provider) !== null,
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
const natural = Math.max(...firstPass.map((line) => visibleLength(line)), MIN_WIDTH - BOX_PADDING);
|
|
335
|
+
const contentWidth = Math.max(MIN_WIDTH - BOX_PADDING, Math.min(natural, hardMax - BOX_PADDING));
|
|
336
|
+
|
|
337
|
+
/** @type {string[]} */
|
|
338
|
+
const out = [];
|
|
339
|
+
const title = " pi quota ";
|
|
340
|
+
// Top border: "╭─" + title + dashes + "╮" must equal the content lines' width.
|
|
341
|
+
out.push(paint("accent", `╭─${title}${"─".repeat(Math.max(0, contentWidth - 9))}╮`));
|
|
342
|
+
|
|
343
|
+
for (const provider of providers) {
|
|
344
|
+
const title = accountTitle(provider);
|
|
345
|
+
for (const line of renderProviderLines(provider, paint, contentWidth, { title, accountInTitle: title !== null })) {
|
|
346
|
+
out.push(`${paint("accent", "│")} ${padVisible(clip(line, contentWidth), contentWidth)} ${paint("accent", "│")}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
out.push(paint("accent", `├${"─".repeat(contentWidth + 2)}┤`));
|
|
351
|
+
const footer = `read-only · ${meta.generatedAt ?? ""}`.trim();
|
|
352
|
+
out.push(`${paint("accent", "│")} ${padVisible(clip(paint("dim", footer), contentWidth), contentWidth)} ${paint("accent", "│")}`);
|
|
353
|
+
for (const warning of meta.warnings ?? []) {
|
|
354
|
+
for (const piece of wrapPlain(warning, contentWidth - 7)) {
|
|
355
|
+
out.push(
|
|
356
|
+
`${paint("accent", "│")} ${padVisible(clip(paint("warn", `warn: ${piece}`), contentWidth), contentWidth)} ${paint("accent", "│")}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
out.push(paint("accent", `╰${"─".repeat(contentWidth + 2)}╯`));
|
|
361
|
+
return out;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Length of a string ignoring ANSI SGR sequences.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} text
|
|
368
|
+
* @returns {number}
|
|
369
|
+
*/
|
|
370
|
+
export function visibleLength(text) {
|
|
371
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Pad to a visible width while preserving embedded ANSI sequences.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} text
|
|
378
|
+
* @param {number} width
|
|
379
|
+
* @returns {string}
|
|
380
|
+
*/
|
|
381
|
+
export function padVisible(text, width) {
|
|
382
|
+
const missing = width - visibleLength(text);
|
|
383
|
+
return missing > 0 ? text + " ".repeat(missing) : text;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* One-line-per-provider summary used when `--compact` is requested.
|
|
388
|
+
*
|
|
389
|
+
* @param {import("../model.js").QuotaResult[]} providers
|
|
390
|
+
* @param {import("./theme.js").Paint} paint
|
|
391
|
+
* @returns {string[]}
|
|
392
|
+
*/
|
|
393
|
+
export function renderCompact(providers, paint) {
|
|
394
|
+
return firstPerFamily(providers).map((provider) => {
|
|
395
|
+
const window = headlineWindow(provider);
|
|
396
|
+
const remaining = window?.remainingPercent ?? null;
|
|
397
|
+
const key = provider.ok ? thresholdKey(remaining) : "unknown";
|
|
398
|
+
let status;
|
|
399
|
+
if (!provider.ok) {
|
|
400
|
+
status = `! ${provider.error}`;
|
|
401
|
+
} else if (remaining === null) {
|
|
402
|
+
status = "no percentage reported";
|
|
403
|
+
} else {
|
|
404
|
+
const reset = window?.resetsInSec !== null && window ? ` · ${humanReset(window.resetsInSec)}` : "";
|
|
405
|
+
status = `${ringGlyph(remaining)} ${Math.round(remaining)}% left${reset}`;
|
|
406
|
+
}
|
|
407
|
+
return `${paint("bold", provider.label.padEnd(20))} ${paint(key, status)}`;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export { clip as truncateVisibleText, wrapPlain as wrapVisibleText };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thresholds and glyph helpers shared by every surface.
|
|
3
|
+
*
|
|
4
|
+
* Remaining (not used) percent drives the color so that a healthy account reads
|
|
5
|
+
* green, a tightening one yellow and a nearly exhausted one red.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {"ok" | "warn" | "danger" | "unknown" | "dim" | "bold" | "accent"} PaletteKey
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {(key: PaletteKey, text: string) => string} Paint
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {number | null} remainingPercent
|
|
18
|
+
* @returns {PaletteKey}
|
|
19
|
+
*/
|
|
20
|
+
export function thresholdKey(remainingPercent) {
|
|
21
|
+
if (remainingPercent === null || !Number.isFinite(remainingPercent)) return "unknown";
|
|
22
|
+
if (remainingPercent > 50) return "ok";
|
|
23
|
+
if (remainingPercent >= 20) return "warn";
|
|
24
|
+
return "danger";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Five-step ring glyph for compact status lines.
|
|
29
|
+
*
|
|
30
|
+
* @param {number | null} remainingPercent
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function ringGlyph(remainingPercent) {
|
|
34
|
+
if (remainingPercent === null || !Number.isFinite(remainingPercent)) return "◌";
|
|
35
|
+
if (remainingPercent >= 87.5) return "●";
|
|
36
|
+
if (remainingPercent >= 62.5) return "◕";
|
|
37
|
+
if (remainingPercent >= 37.5) return "◑";
|
|
38
|
+
if (remainingPercent >= 12.5) return "◔";
|
|
39
|
+
return "○";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Percentage bar for the detailed panel.
|
|
44
|
+
*
|
|
45
|
+
* @param {number | null} remainingPercent
|
|
46
|
+
* @param {number} [width]
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function bar(remainingPercent, width = 14) {
|
|
50
|
+
if (remainingPercent === null || !Number.isFinite(remainingPercent)) {
|
|
51
|
+
return "·".repeat(width);
|
|
52
|
+
}
|
|
53
|
+
const filled = Math.round((Math.min(100, Math.max(0, remainingPercent)) / 100) * width);
|
|
54
|
+
return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Round a percent for display, tolerating missing values.
|
|
59
|
+
*
|
|
60
|
+
* @param {number | null} value
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function percentText(value) {
|
|
64
|
+
if (value === null || !Number.isFinite(value)) return " n/a";
|
|
65
|
+
return `${Math.round(value)}%`.padStart(5);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ANSI = {
|
|
69
|
+
ok: "\u001b[32m",
|
|
70
|
+
warn: "\u001b[33m",
|
|
71
|
+
danger: "\u001b[31m",
|
|
72
|
+
unknown: "\u001b[90m",
|
|
73
|
+
dim: "\u001b[2m",
|
|
74
|
+
bold: "\u001b[1m",
|
|
75
|
+
accent: "\u001b[36m",
|
|
76
|
+
reset: "\u001b[0m",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* ANSI palette used by the terminal CLI.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ color?: boolean, env?: Record<string, string | undefined>, isTty?: boolean }} [options]
|
|
83
|
+
* @returns {Paint}
|
|
84
|
+
*/
|
|
85
|
+
export function ansiPalette(options = {}) {
|
|
86
|
+
const env = options.env ?? process.env;
|
|
87
|
+
const isTty = options.isTty ?? Boolean(process.stdout?.isTTY);
|
|
88
|
+
const enabled =
|
|
89
|
+
options.color ?? (isTty && env.NO_COLOR === undefined && env.TERM !== "dumb");
|
|
90
|
+
if (!enabled) return (_key, text) => text;
|
|
91
|
+
return (key, text) => `${ANSI[key] ?? ""}${text}${ANSI.reset}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Identity palette used when the caller already styles its own output
|
|
96
|
+
* (for example the Pi TUI, which paints through its theme).
|
|
97
|
+
*
|
|
98
|
+
* @returns {Paint}
|
|
99
|
+
*/
|
|
100
|
+
export function plainPalette() {
|
|
101
|
+
return (_key, text) => text;
|
|
102
|
+
}
|