pi-zro-provider 1.0.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/status.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Footer status presentation for the Zro extension.
3
+ *
4
+ * Pure functions + a width-aware widget component — no pi runtime imports.
5
+ * index.ts owns fetching, events, and config persistence; this module turns
6
+ * state snapshots into terminal strings.
7
+ *
8
+ * Layout (below-editor widget, neuralwatt style):
9
+ *
10
+ * ⚡ $0.42 · 7 req Pro ◆ $12.34 avail · $2.50 pack · 1.2k req/30d
11
+ * └ left: session activity ─┘ └───────────── right: account / quota ─────────┘
12
+ *
13
+ * The left side is preserved at full fidelity. The right side is rendered
14
+ * from a tier list (most → least detailed); render() picks the first tier
15
+ * that fits the remaining width, and as a last resort truncates the minimal
16
+ * tier. Width math counts only terminal-visible columns (ANSI-aware, wide
17
+ * glyphs like ⚡ ⚠ measure 2 columns).
18
+ */
19
+
20
+ export type DisplayMode = "widget" | "statusbar" | "off";
21
+
22
+ export interface StatusConfig {
23
+ session: DisplayMode;
24
+ account: DisplayMode;
25
+ hideOnOtherProvider: boolean;
26
+ lowBalanceUsd: number | null;
27
+ }
28
+
29
+ export const DEFAULT_STATUS_CONFIG: StatusConfig = {
30
+ session: "widget",
31
+ account: "widget",
32
+ hideOnOtherProvider: true,
33
+ lowBalanceUsd: 10,
34
+ };
35
+
36
+ const VALID_MODES = new Set<string>(["widget", "statusbar", "off"]);
37
+
38
+ function coerceMode(value: unknown, fallback: DisplayMode): DisplayMode {
39
+ return typeof value === "string" && VALID_MODES.has(value) ? (value as DisplayMode) : fallback;
40
+ }
41
+
42
+ export function coerceStatusConfig(raw: unknown): StatusConfig {
43
+ const d = DEFAULT_STATUS_CONFIG;
44
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ...d };
45
+ const r = raw as Record<string, unknown>;
46
+ return {
47
+ session: coerceMode(r.session, d.session),
48
+ account: coerceMode(r.account, d.account),
49
+ hideOnOtherProvider: typeof r.hideOnOtherProvider === "boolean" ? r.hideOnOtherProvider : d.hideOnOtherProvider,
50
+ lowBalanceUsd:
51
+ typeof r.lowBalanceUsd === "number" && Number.isFinite(r.lowBalanceUsd) && r.lowBalanceUsd > 0
52
+ ? r.lowBalanceUsd
53
+ : r.lowBalanceUsd === null || r.lowBalanceUsd === false
54
+ ? null
55
+ : d.lowBalanceUsd,
56
+ };
57
+ }
58
+
59
+ // State snapshots
60
+ export interface RateLimitState {
61
+ limit: number;
62
+ remaining: number;
63
+ capturedAt: number;
64
+ }
65
+
66
+ export interface AccountState {
67
+ availableUsd: number | null;
68
+ planName: string | null;
69
+ usagePackUsd: number | null;
70
+ activity30dRequests: number | null;
71
+ activity30dTokens: number | null;
72
+ rate: RateLimitState | null;
73
+ keyAlias: string | null;
74
+ }
75
+
76
+ export const EMPTY_ACCOUNT: AccountState = {
77
+ availableUsd: null,
78
+ planName: null,
79
+ usagePackUsd: null,
80
+ activity30dRequests: null,
81
+ activity30dTokens: null,
82
+ rate: null,
83
+ keyAlias: null,
84
+ };
85
+
86
+ export interface SessionStats {
87
+ requests: number;
88
+ tokens: number;
89
+ spend: number;
90
+ }
91
+
92
+ export const EMPTY_SESSION_STATS: SessionStats = { requests: 0, tokens: 0, spend: 0 };
93
+
94
+ export function applyOptimisticSpend(acc: AccountState, spendUsd: number): void {
95
+ if (spendUsd > 0 && acc.availableUsd !== null) {
96
+ acc.availableUsd = Math.max(0, acc.availableUsd - spendUsd);
97
+ }
98
+ }
99
+
100
+ // Formatters
101
+ function trimZeros(text: string): string {
102
+ return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
103
+ }
104
+
105
+ export function formatUsd(n: number): string {
106
+ if (!Number.isFinite(n)) return "?";
107
+ if (n <= 0) return "$0";
108
+ if (n < 0.01) return "~$0.01";
109
+ const abs = Math.abs(n);
110
+ if (abs >= 1_000_000) return `$${trimZeros((n / 1_000_000).toFixed(2))}M`;
111
+ if (abs >= 10_000) return `$${trimZeros((n / 1_000).toFixed(1))}k`;
112
+ if (abs >= 100) return `$${Math.round(n)}`;
113
+ return `$${trimZeros(n.toFixed(2))}`;
114
+ }
115
+
116
+ export function formatTokens(n: number): string {
117
+ if (!Number.isFinite(n)) return "?";
118
+ if (n <= 0) return "0";
119
+ if (n < 1000) return String(Math.round(n));
120
+ if (n < 1_000_000) return `${trimZeros((n / 1000).toFixed(1))}k`;
121
+ return `${trimZeros((n / 1_000_000).toFixed(2))}M`;
122
+ }
123
+
124
+ export function formatCount(n: number): string {
125
+ if (!Number.isFinite(n)) return "?";
126
+ if (n >= 1_000_000) return `${trimZeros((n / 1_000_000).toFixed(2))}M`;
127
+ if (n >= 1000) return `${trimZeros((n / 1000).toFixed(1))}k`;
128
+ return String(Math.max(0, Math.round(n)));
129
+ }
130
+
131
+ // Line builders
132
+ export function buildSessionLine(stats: SessionStats): string | undefined {
133
+ if (stats.requests <= 0 && stats.tokens <= 0 && stats.spend <= 0) return undefined;
134
+ const req = `${stats.requests} req`;
135
+ if (stats.spend > 0) return `⚡ ${formatUsd(stats.spend)} · ${req}`;
136
+ if (stats.tokens > 0) return `⚡ ${formatTokens(stats.tokens)} tok · ${req}`;
137
+ return `⚡ ${req}`;
138
+ }
139
+
140
+ export function accountHasData(acc: AccountState): boolean {
141
+ return (
142
+ acc.availableUsd !== null ||
143
+ acc.planName !== null ||
144
+ acc.usagePackUsd !== null ||
145
+ acc.activity30dRequests !== null ||
146
+ acc.activity30dTokens !== null ||
147
+ acc.rate !== null ||
148
+ acc.keyAlias !== null
149
+ );
150
+ }
151
+
152
+ export function buildAccountTiers(acc: AccountState, lowBalance: boolean): string[] {
153
+ const gem = lowBalance ? "⚠ ◆" : "◆";
154
+ const bal = acc.availableUsd !== null ? `${gem} ${formatUsd(acc.availableUsd)} avail` : undefined;
155
+ const plan = acc.planName?.trim() || undefined;
156
+ const pack = acc.usagePackUsd !== null && acc.usagePackUsd > 0 ? `${formatUsd(acc.usagePackUsd)} pack` : undefined;
157
+ const actTok = acc.activity30dTokens !== null && acc.activity30dTokens > 0 ? `${formatTokens(acc.activity30dTokens)} tok/30d` : undefined;
158
+ const actReq = acc.activity30dRequests !== null ? `${formatCount(acc.activity30dRequests)} req/30d` : undefined;
159
+ const rate = acc.rate !== null ? `${formatCount(acc.rate.remaining)}/${formatCount(acc.rate.limit)}` : undefined;
160
+ const head = [plan, bal].filter((p): p is string => !!p).join(" ") || undefined;
161
+ const numOnly = acc.availableUsd !== null ? formatUsd(acc.availableUsd) : undefined;
162
+
163
+ const join = (parts: (string | undefined)[]) => parts.filter((p): p is string => !!p).join(" · ");
164
+
165
+ const tiers: string[] = [
166
+ join([head, pack, actTok, actReq, rate]),
167
+ join([head, pack, actReq, rate]),
168
+ join([head, pack, actReq]),
169
+ join([head, actTok, actReq]),
170
+ join([head, actReq]),
171
+ join([head, rate]),
172
+ join([head]),
173
+ join([bal, actReq]),
174
+ join([bal]),
175
+ join([numOnly]),
176
+ ];
177
+
178
+ // Dedupe adjacent identical tiers (happens when atoms are missing).
179
+ // Enforce monotonic non-increasing width so render() can trust ordering
180
+ // even on pathological data (a tier wider than its predecessor is dropped).
181
+ const out: string[] = [];
182
+ for (const t of tiers) {
183
+ if (!t || t === out[out.length - 1]) continue;
184
+ if (out.length > 0 && termVisWidth(t) > termVisWidth(out[out.length - 1])) continue;
185
+ out.push(t);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ // Terminal width math (adapted from pi-neuralwatt-provider)
191
+ const EMOJI_RE = /\p{Emoji_Presentation}/u;
192
+ const AMBIGUOUS_WIDE = new Set(["■", "▲", "◉"]);
193
+
194
+ export function termVisWidth(str: string): number {
195
+ let width = 0;
196
+ let i = 0;
197
+ while (i < str.length) {
198
+ const code = str.charCodeAt(i);
199
+ if (code === 0x1b && i + 1 < str.length) {
200
+ const next = str.charCodeAt(i + 1);
201
+ if (next === 0x5b) {
202
+ i += 2;
203
+ while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
204
+ while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) i++;
205
+ if (i < str.length) i++;
206
+ continue;
207
+ }
208
+ }
209
+ const cp = str.codePointAt(i)!;
210
+ const char = cp > 0xffff ? str.slice(i, i + 2) : str[i];
211
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
212
+ width += 1;
213
+ } else if (EMOJI_RE.test(char)) {
214
+ width += 2;
215
+ } else if (AMBIGUOUS_WIDE.has(char)) {
216
+ width += 2;
217
+ } else {
218
+ width += 1;
219
+ }
220
+ i += cp > 0xffff ? 2 : 1;
221
+ }
222
+ return width;
223
+ }
224
+
225
+ export function truncateAnsi(str: string, maxCols: number): string {
226
+ if (maxCols <= 0) return "";
227
+ if (termVisWidth(str) <= maxCols) return str;
228
+ let result = "";
229
+ let visWidth = 0;
230
+ let i = 0;
231
+ const target = maxCols - 1;
232
+ while (i < str.length) {
233
+ const code = str.charCodeAt(i);
234
+ if (code === 0x1b && i + 1 < str.length && str.charCodeAt(i + 1) === 0x5b) {
235
+ const start = i;
236
+ i += 2;
237
+ while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
238
+ while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) i++;
239
+ if (i < str.length) i++;
240
+ result += str.slice(start, i);
241
+ continue;
242
+ }
243
+ const cp = str.codePointAt(i)!;
244
+ const char = cp > 0xffff ? str.slice(i, i + 2) : str[i];
245
+ let charWidth: number;
246
+ if (cp >= 0x1f1e6 && cp <= 0x1f1ff) charWidth = 1;
247
+ else if (EMOJI_RE.test(char)) charWidth = 2;
248
+ else if (AMBIGUOUS_WIDE.has(char)) charWidth = 2;
249
+ else charWidth = 1;
250
+ if (visWidth + charWidth > target) break;
251
+ result += char;
252
+ visWidth += charWidth;
253
+ i += cp > 0xffff ? 2 : 1;
254
+ }
255
+ return result + "…";
256
+ }
257
+
258
+ export interface LineTheme {
259
+ fg(color: string, text: string): string;
260
+ }
261
+
262
+ export class StatusLineWidget {
263
+ private theme: LineTheme;
264
+ private leftRaw: string;
265
+ private rightTiers: string[];
266
+ private rightWarn: boolean;
267
+
268
+ constructor(theme: LineTheme, leftRaw: string, rightTiers: string[] = [], rightWarn = false) {
269
+ this.theme = theme;
270
+ this.leftRaw = leftRaw;
271
+ this.rightTiers = rightTiers;
272
+ this.rightWarn = rightWarn;
273
+ }
274
+
275
+ invalidate(): void {}
276
+
277
+ render(width: number): string[] {
278
+ const leftVis = termVisWidth(this.leftRaw);
279
+ if (leftVis > width) {
280
+ return [this.theme.fg("dim", truncateAnsi(this.leftRaw, width))];
281
+ }
282
+
283
+ const rightColor = this.rightWarn ? "warning" : "dim";
284
+ const themedLeft = this.theme.fg("dim", this.leftRaw);
285
+ const budget = width - leftVis - 1;
286
+
287
+ for (const tier of this.rightTiers) {
288
+ if (termVisWidth(tier) <= budget) {
289
+ const themedRight = this.theme.fg(rightColor, tier);
290
+ const pad = width - termVisWidth(themedLeft) - termVisWidth(themedRight);
291
+ return [themedLeft + " ".repeat(Math.max(1, pad)) + themedRight];
292
+ }
293
+ }
294
+
295
+ const pad = width - termVisWidth(themedLeft);
296
+ return [themedLeft + " ".repeat(Math.max(0, pad))];
297
+ }
298
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Dependency-free smoke test for the footer-status presentation module.
3
+ * Run: node tests/status.smoke.ts (Node ≥ 23 strips types natively).
4
+ * Exercises tier building, width math, widget layout, and config coercion —
5
+ * the pieces where a regression would silently corrupt the footer line.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import {
9
+ EMPTY_ACCOUNT,
10
+ StatusLineWidget,
11
+ accountHasData,
12
+ applyOptimisticSpend,
13
+ buildAccountTiers,
14
+ buildSessionLine,
15
+ coerceStatusConfig,
16
+ formatCount,
17
+ formatTokens,
18
+ formatUsd,
19
+ termVisWidth,
20
+ truncateAnsi,
21
+ type AccountState,
22
+ } from "../status.ts";
23
+
24
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
25
+ const fakeTheme = { fg: (_c: string, t: string) => `\x1b[2m${t}\x1b[39m` };
26
+
27
+ // ── formatters ──
28
+ assert.equal(formatUsd(0), "$0");
29
+ assert.equal(formatUsd(12.34), "$12.34");
30
+ assert.equal(formatUsd(0.5), "$0.5");
31
+ assert.equal(formatUsd(249), "$249");
32
+ assert.equal(formatUsd(12345), "$12.3k");
33
+ assert.equal(formatUsd(1_250_000), "$1.25M");
34
+ assert.equal(formatUsd(0.0004), "~$0.01");
35
+ assert.equal(formatTokens(0), "0");
36
+ assert.equal(formatTokens(850), "850");
37
+ assert.equal(formatTokens(48200), "48.2k");
38
+ assert.equal(formatTokens(1_048_576), "1.05M");
39
+ assert.equal(formatCount(7), "7");
40
+ assert.equal(formatCount(1200), "1.2k");
41
+ assert.equal(formatCount(12_000), "12k");
42
+
43
+ // ── session line ──
44
+ assert.equal(buildSessionLine({ requests: 0, tokens: 0, spend: 0 }), undefined);
45
+ assert.equal(buildSessionLine({ requests: 7, tokens: 48200, spend: 0 }), "⚡ 48.2k tok · 7 req");
46
+ assert.equal(buildSessionLine({ requests: 7, tokens: 0, spend: 0.42 }), "⚡ $0.42 · 7 req");
47
+ assert.equal(buildSessionLine({ requests: 1, tokens: 0, spend: 0 }), "⚡ 1 req");
48
+
49
+ // ── account tiers ──
50
+ const acc = (over: Partial<AccountState>): AccountState => ({ ...EMPTY_ACCOUNT, ...over });
51
+ const rate = { limit: 1000, remaining: 996, capturedAt: 0 };
52
+
53
+ assert.equal(accountHasData(acc({})), false);
54
+ assert.equal(accountHasData(acc({ availableUsd: 0 })), true);
55
+
56
+ const full = acc({ availableUsd: 12.34, planName: "Pro", usagePackUsd: 2.5, activity30dRequests: 1200, activity30dTokens: 850000, rate });
57
+ const tiers = buildAccountTiers(full, false);
58
+ assert.equal(tiers[0], "Pro ◆ $12.34 avail · $2.5 pack · 850k tok/30d · 1.2k req/30d · 996/1k");
59
+ assert.ok(tiers.includes("Pro ◆ $12.34 avail"));
60
+ assert.ok(tiers.includes("◆ $12.34 avail"));
61
+ assert.ok(tiers.includes("$12.34"));
62
+ // tiers must be strictly non-increasing in width
63
+ for (let i = 1; i < tiers.length; i++) {
64
+ assert.ok(termVisWidth(tiers[i]) <= termVisWidth(tiers[i - 1]), `tier ${i} wider than previous`);
65
+ }
66
+ // dedupe of adjacent identical tiers when atoms are missing
67
+ assert.deepEqual(buildAccountTiers(acc({ planName: "Pro" }), false), ["Pro"]);
68
+ const balOnly = buildAccountTiers(acc({ availableUsd: 5 }), true);
69
+ assert.equal(balOnly[0], "⚠ ◆ $5 avail");
70
+ assert.ok(balOnly.includes("$5"));
71
+
72
+ // optimistic spend deduction
73
+ {
74
+ const opt = acc({ availableUsd: 12.34 });
75
+ applyOptimisticSpend(opt, 0.5);
76
+ assert.equal(opt.availableUsd, 11.84);
77
+ applyOptimisticSpend(opt, 0); // zero spend is a no-op
78
+ assert.equal(opt.availableUsd, 11.84);
79
+ applyOptimisticSpend(opt, 300); // clamps at 0, never negative
80
+ assert.equal(opt.availableUsd, 0);
81
+ const unknown = acc({ availableUsd: null });
82
+ applyOptimisticSpend(unknown, 1); // unknown balance stays unknown
83
+ assert.equal(unknown.availableUsd, null);
84
+ }
85
+
86
+ // ── width math ──
87
+ assert.equal(termVisWidth("abc"), 3);
88
+ assert.equal(termVisWidth(""), 0);
89
+ assert.equal(termVisWidth(fakeTheme.fg("dim", "abc")), 3, "ANSI is zero-width");
90
+ assert.equal(termVisWidth("◆"), 1, "◆ counts as narrow in this terminal");
91
+ assert.equal(termVisWidth("⚡"), 2);
92
+ assert.equal(truncateAnsi("hello world", 8), "hello w…");
93
+ assert.equal(termVisWidth(truncateAnsi(fakeTheme.fg("x", "hello world"), 8)), 8);
94
+ assert.equal(truncateAnsi("abc", 5), "abc");
95
+ assert.equal(truncateAnsi("abc", 0), "");
96
+
97
+ // ── widget render ──
98
+ const left = buildSessionLine({ requests: 7, tokens: 0, spend: 0.42 })!;
99
+ const widget = new StatusLineWidget(fakeTheme, left, tiers, false);
100
+
101
+ // Wide: full tier, left-right justified, exactly width columns
102
+ const wide = widget.render(80);
103
+ assert.equal(wide.length, 1);
104
+ assert.equal(termVisWidth(wide[0]), 80);
105
+ assert.ok(stripAnsi(wide[0]).startsWith("⚡ $0.42"));
106
+ assert.ok(stripAnsi(wide[0]).endsWith("996/1k"));
107
+
108
+ // Medium: drops to a compressed tier, still exactly width
109
+ const med = widget.render(52);
110
+ assert.equal(termVisWidth(med[0]), 52);
111
+ assert.ok(!stripAnsi(med[0]).includes("tok/30d"), "compressed tiers drop tokens activity first");
112
+
113
+ // Narrow: no tier fits → left only, padded
114
+ const narrow = widget.render(termVisWidth(left) + 3);
115
+ assert.equal(termVisWidth(narrow[0]), termVisWidth(left) + 3);
116
+ assert.ok(stripAnsi(narrow[0]).startsWith("⚡"));
117
+ assert.ok(!stripAnsi(narrow[0]).includes("◆"));
118
+
119
+ // Narrower than left itself: truncation never overflows (crash guard)
120
+ const tiny = widget.render(10);
121
+ assert.equal(termVisWidth(tiny[0]), 10);
122
+
123
+ // Left empty (session gated) → right-aligned account line
124
+ const rightOnly = new StatusLineWidget(fakeTheme, "", tiers, false);
125
+ const ro = rightOnly.render(70);
126
+ assert.equal(termVisWidth(ro[0]), 70);
127
+ assert.ok(stripAnsi(ro[0]).endsWith("996/1k"));
128
+
129
+ // No data at all
130
+ assert.deepEqual(new StatusLineWidget(fakeTheme, "", [], false).render(40), [fakeTheme.fg("dim", "") + " ".repeat(40)]);
131
+
132
+ // Warning color wired through
133
+ const warn = new StatusLineWidget(fakeTheme, "", buildAccountTiers(acc({ availableUsd: 5 }), true), true);
134
+ const warnLine = warn.render(60)[0];
135
+ assert.ok(stripAnsi(warnLine).includes("⚠ ◆ $5 avail"));
136
+ assert.ok(warnLine.includes("warning") || true); // fakeTheme ignores color names
137
+ const markTheme = { fg: (c: string, t: string) => `<${c}>${t}</>` };
138
+ assert.ok(new StatusLineWidget(markTheme, "", buildAccountTiers(acc({ availableUsd: 5 }), true), true).render(60)[0].includes("<warning>"));
139
+ assert.ok(new StatusLineWidget(markTheme, "", buildAccountTiers(acc({ availableUsd: 5 }), true), false).render(60)[0].includes("<dim>"));
140
+
141
+ // ── config coercion ──
142
+ assert.deepEqual(coerceStatusConfig(undefined), {
143
+ session: "widget",
144
+ account: "widget",
145
+ hideOnOtherProvider: true,
146
+ lowBalanceUsd: 10,
147
+ });
148
+ assert.deepEqual(coerceStatusConfig({ session: "bogus", lowBalanceUsd: -3 }), {
149
+ session: "widget",
150
+ account: "widget",
151
+ hideOnOtherProvider: true,
152
+ lowBalanceUsd: 10,
153
+ });
154
+ assert.deepEqual(coerceStatusConfig({ session: "statusbar", account: "off", hideOnOtherProvider: false, lowBalanceUsd: null }), {
155
+ session: "statusbar",
156
+ account: "off",
157
+ hideOnOtherProvider: false,
158
+ lowBalanceUsd: null,
159
+ });
160
+ assert.equal(coerceStatusConfig({ lowBalanceUsd: 42 }).lowBalanceUsd, 42);
161
+ assert.equal(coerceStatusConfig({ lowBalanceUsd: false }).lowBalanceUsd, null);
162
+ assert.deepEqual(coerceStatusConfig(null).session, "widget");
163
+
164
+ console.log("status.smoke: all assertions passed");
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "esModuleInterop": true,
8
+ "strict": false,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "resolveJsonModule": true
12
+ },
13
+ "include": ["./*.ts"],
14
+ "exclude": ["node_modules", "tests"]
15
+ }