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/.github/FUNDING.yml +4 -0
- package/AGENTS.md +58 -0
- package/LICENSE +21 -0
- package/README.md +232 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +1 -0
- package/index.ts +1110 -0
- package/models.json +89 -0
- package/package.json +40 -0
- package/patch.json +1 -0
- package/pnpm-workspace.yaml +15 -0
- package/scripts/update-models.js +387 -0
- package/status.ts +298 -0
- package/tests/status.smoke.ts +164 -0
- package/tsconfig.json +15 -0
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
|
+
}
|