@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +509 -2
  3. package/bin/piquota.js +199 -0
  4. package/extensions/quota-panel.ts +963 -0
  5. package/package.json +59 -4
  6. package/src/auth/claude-code-auth.js +490 -0
  7. package/src/auth/jwt.js +84 -0
  8. package/src/auth/pi-auth.js +424 -0
  9. package/src/browser/cookies.js +557 -0
  10. package/src/browser/history.js +173 -0
  11. package/src/cache.js +224 -0
  12. package/src/cli/args.js +154 -0
  13. package/src/cli/commands/auth.js +216 -0
  14. package/src/cli/commands/gentle-pi.js +44 -0
  15. package/src/cli/commands/omarchy.js +75 -0
  16. package/src/cli/explain.js +99 -0
  17. package/src/cli/output.js +34 -0
  18. package/src/engine.js +404 -0
  19. package/src/exec.js +31 -0
  20. package/src/gentle-pi/rail-patch.js +335 -0
  21. package/src/http.js +182 -0
  22. package/src/model.js +332 -0
  23. package/src/omarchy/assets/antigravity.svg +3 -0
  24. package/src/omarchy/assets/claude.svg +1 -0
  25. package/src/omarchy/assets/codex.svg +1 -0
  26. package/src/omarchy/assets/github-actions.svg +3 -0
  27. package/src/omarchy/assets/opencode-go.svg +3 -0
  28. package/src/omarchy/publish.js +152 -0
  29. package/src/omarchy/record.js +462 -0
  30. package/src/opencode/dashboard.js +337 -0
  31. package/src/opencode/session.js +485 -0
  32. package/src/providers/antigravity-oauth.js +173 -0
  33. package/src/providers/antigravity.js +225 -0
  34. package/src/providers/backoff.js +146 -0
  35. package/src/providers/claude.js +194 -0
  36. package/src/providers/codex.js +151 -0
  37. package/src/providers/error-kind.js +128 -0
  38. package/src/providers/github-actions.js +379 -0
  39. package/src/providers/opencode-go.js +274 -0
  40. package/src/refresh.js +292 -0
  41. package/src/render/panel.js +411 -0
  42. package/src/render/theme.js +102 -0
  43. 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
+ }