pi-hypercharm-provider 1.3.1 → 1.3.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/.github/FUNDING.yml +4 -0
- package/.pi/fabric/mcp-cache.json +298 -0
- package/.pi/fabric/mesh/state.json +32 -0
- package/.pi/messenger/session-id +1 -0
- package/AGENTS.md +58 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +1 -0
- package/models.json +781 -0
- package/package.json +1 -5
- package/patch.json +1 -0
- package/pnpm-workspace.yaml +15 -0
- package/scripts/update-models.js +529 -0
- package/status.ts +318 -0
- package/tests/status.smoke.ts +161 -0
- package/tsconfig.json +15 -0
package/status.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer status presentation for the HyperCharm 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
|
+
* ⚡ 1.24 hc · 7 req ACME Team ◆ 249 hc · 996/1k/h · 29d
|
|
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 spend/request line (left side). */
|
|
24
|
+
session: DisplayMode;
|
|
25
|
+
/** Team/balance/rate-limit line (right side). */
|
|
26
|
+
account: DisplayMode;
|
|
27
|
+
/** Hide everything when the active model is not from this provider. */
|
|
28
|
+
hideOnOtherProvider: boolean;
|
|
29
|
+
/** Warn (⚠ + highlight) when balance drops to this many hc. null = never. */
|
|
30
|
+
lowBalanceHc: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_STATUS_CONFIG: StatusConfig = {
|
|
34
|
+
session: "widget",
|
|
35
|
+
account: "widget",
|
|
36
|
+
hideOnOtherProvider: true,
|
|
37
|
+
lowBalanceHc: 25,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const VALID_MODES = new Set<string>(["widget", "statusbar", "off"]);
|
|
41
|
+
|
|
42
|
+
function coerceMode(value: unknown, fallback: DisplayMode): DisplayMode {
|
|
43
|
+
return typeof value === "string" && VALID_MODES.has(value) ? (value as DisplayMode) : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Merge an unknown raw JSON object onto the defaults, field by field. */
|
|
47
|
+
export function coerceStatusConfig(raw: unknown): StatusConfig {
|
|
48
|
+
const d = DEFAULT_STATUS_CONFIG;
|
|
49
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { ...d };
|
|
50
|
+
const r = raw as Record<string, unknown>;
|
|
51
|
+
return {
|
|
52
|
+
session: coerceMode(r.session, d.session),
|
|
53
|
+
account: coerceMode(r.account, d.account),
|
|
54
|
+
hideOnOtherProvider: typeof r.hideOnOtherProvider === "boolean" ? r.hideOnOtherProvider : d.hideOnOtherProvider,
|
|
55
|
+
lowBalanceHc:
|
|
56
|
+
typeof r.lowBalanceHc === "number" && Number.isFinite(r.lowBalanceHc) && r.lowBalanceHc > 0
|
|
57
|
+
? r.lowBalanceHc
|
|
58
|
+
: r.lowBalanceHc === null || r.lowBalanceHc === false
|
|
59
|
+
? null
|
|
60
|
+
: d.lowBalanceHc,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── State snapshots ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export interface RateLimitState {
|
|
67
|
+
limitHour: number;
|
|
68
|
+
limitDay: number;
|
|
69
|
+
remainingHour: number;
|
|
70
|
+
remainingDay: number;
|
|
71
|
+
capturedAt: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AccountState {
|
|
75
|
+
/** Canonical balance in hypercredits, from /v1/credits. */
|
|
76
|
+
balance: number | null;
|
|
77
|
+
/** Team display name, from /v1/teams (works for API-key auth too). */
|
|
78
|
+
teamName: string | null;
|
|
79
|
+
/** Latest x-ratelimit-* headers from a /chat/completions response. */
|
|
80
|
+
rate: RateLimitState | null;
|
|
81
|
+
/** Days until the OAuth device session expires (from /v1/devices). */
|
|
82
|
+
authDaysLeft: number | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const EMPTY_ACCOUNT: AccountState = {
|
|
86
|
+
balance: null,
|
|
87
|
+
teamName: null,
|
|
88
|
+
rate: null,
|
|
89
|
+
authDaysLeft: null,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface SessionStats {
|
|
93
|
+
requests: number;
|
|
94
|
+
spendHc: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const EMPTY_SESSION_STATS: SessionStats = { requests: 0, spendHc: 0 };
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Optimistically deduct observed turn spend from the last polled balance.
|
|
101
|
+
* Safe against double-counting only because callers overwrite (never adjust)
|
|
102
|
+
* `balance` on every credits poll — the agent_settled poll reconciles drift.
|
|
103
|
+
* Unknown balances stay unknown; estimates clamp at 0 (real exhaustion is
|
|
104
|
+
* still signaled by the 402 path, not by an estimated zero).
|
|
105
|
+
*/
|
|
106
|
+
export function applyOptimisticSpend(acc: AccountState, spendHc: number): void {
|
|
107
|
+
if (spendHc > 0 && acc.balance !== null) {
|
|
108
|
+
acc.balance = Math.max(0, acc.balance - spendHc);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function trimZeros(text: string): string {
|
|
115
|
+
return text.includes(".") ? text.replace(/0+$/, "").replace(/\.$/, "") : text;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Balance in hypercredits: integers get group separators, large values compact. */
|
|
119
|
+
export function formatBalHc(n: number): string {
|
|
120
|
+
if (!Number.isFinite(n)) return "?";
|
|
121
|
+
const abs = Math.abs(n);
|
|
122
|
+
if (abs >= 1_000_000) return `${trimZeros((n / 1_000_000).toFixed(2))}M`;
|
|
123
|
+
if (abs >= 10_000) return `${trimZeros((n / 1_000).toFixed(1))}k`;
|
|
124
|
+
return Number.isInteger(n) ? n.toLocaleString("en-US") : trimZeros(n.toFixed(2));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Session-cumulative spend: meaningful at small magnitudes, so keep precision. */
|
|
128
|
+
export function formatSpendHc(n: number): string {
|
|
129
|
+
if (!Number.isFinite(n) || n <= 0) return "0";
|
|
130
|
+
if (n < 0.001) return "~0";
|
|
131
|
+
if (n < 0.01) return trimZeros(n.toFixed(4));
|
|
132
|
+
if (n < 1000) return trimZeros(n.toFixed(2));
|
|
133
|
+
return Math.round(n).toLocaleString("en-US");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Rate-limit counts: exact when small, compact k when ≥ 1000. */
|
|
137
|
+
export function formatRateCompact(n: number): string {
|
|
138
|
+
if (!Number.isFinite(n)) return "?";
|
|
139
|
+
if (n >= 1000) return `${trimZeros((n / 1000).toFixed(1))}k`;
|
|
140
|
+
return String(Math.max(0, Math.round(n)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Line builders ────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** Left side: what this session has spent/requested through HyperCharm. */
|
|
146
|
+
export function buildSessionLine(stats: SessionStats): string | undefined {
|
|
147
|
+
if (stats.requests <= 0 && stats.spendHc <= 0) return undefined;
|
|
148
|
+
return `⚡ ${formatSpendHc(stats.spendHc)} hc · ${stats.requests} req`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function accountHasData(acc: AccountState): boolean {
|
|
152
|
+
return acc.balance !== null || acc.teamName !== null || acc.rate !== null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Right side as progressive tiers — entries share no summary separator;
|
|
157
|
+
* atoms are joined with " · ". Render picks the first that fits.
|
|
158
|
+
*/
|
|
159
|
+
export function buildAccountTiers(acc: AccountState, lowBalance: boolean): string[] {
|
|
160
|
+
const gem = lowBalance ? "⚠ ◆" : "◆";
|
|
161
|
+
const bal = acc.balance !== null ? `${gem} ${formatBalHc(acc.balance)} hc` : undefined;
|
|
162
|
+
const hourRate =
|
|
163
|
+
acc.rate !== null ? `${formatRateCompact(acc.rate.remainingHour)}/${formatRateCompact(acc.rate.limitHour)}/h` : undefined;
|
|
164
|
+
const dayRate =
|
|
165
|
+
acc.rate !== null ? `${formatRateCompact(acc.rate.remainingDay)}/${formatRateCompact(acc.rate.limitDay)}/d` : undefined;
|
|
166
|
+
const auth = acc.authDaysLeft !== null ? `⟳ ${acc.authDaysLeft}d` : undefined;
|
|
167
|
+
const team = acc.teamName?.trim() || undefined;
|
|
168
|
+
// Team and gem form one identity unit (space-separated, no middot);
|
|
169
|
+
// rate-limit and auth atoms trail it separated by " · ".
|
|
170
|
+
const head = [team, bal].filter((p): p is string => !!p).join(" ") || undefined;
|
|
171
|
+
const numOnly = acc.balance !== null ? `${formatBalHc(acc.balance)} hc` : undefined;
|
|
172
|
+
|
|
173
|
+
const join = (parts: (string | undefined)[]) => parts.filter((p): p is string => !!p).join(" · ");
|
|
174
|
+
|
|
175
|
+
const tiers: string[] = [
|
|
176
|
+
join([head, hourRate, dayRate, auth]),
|
|
177
|
+
join([head, hourRate, auth]),
|
|
178
|
+
join([head, hourRate]),
|
|
179
|
+
join([bal, hourRate]),
|
|
180
|
+
join([head]),
|
|
181
|
+
join([team]),
|
|
182
|
+
join([hourRate]),
|
|
183
|
+
join([numOnly]),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
// Dedupe adjacent identical tiers (happens when atoms are missing).
|
|
187
|
+
const out: string[] = [];
|
|
188
|
+
for (const t of tiers) {
|
|
189
|
+
if (t && t !== out[out.length - 1]) out.push(t);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Terminal width math ──────────────────────────────────────────────────────
|
|
195
|
+
// Adapted from pi-neuralwatt-provider: ANSI-aware, wide-glyph-aware column
|
|
196
|
+
// counting. ◆ is ambiguous-width but this terminal class renders it wide.
|
|
197
|
+
|
|
198
|
+
const EMOJI_RE = /\p{Emoji_Presentation}/u;
|
|
199
|
+
// East-Asian-Ambiguous glyphs some terminals render as 2 columns. ◆ is NOT
|
|
200
|
+
// listed: pi widths it as 1 here, and counting it wide leaves a trailing gap
|
|
201
|
+
// before the right edge.
|
|
202
|
+
const AMBIGUOUS_WIDE = new Set(["■", "▲", "◉"]);
|
|
203
|
+
|
|
204
|
+
export function termVisWidth(str: string): number {
|
|
205
|
+
let width = 0;
|
|
206
|
+
let i = 0;
|
|
207
|
+
while (i < str.length) {
|
|
208
|
+
const code = str.charCodeAt(i);
|
|
209
|
+
if (code === 0x1b && i + 1 < str.length) {
|
|
210
|
+
const next = str.charCodeAt(i + 1);
|
|
211
|
+
if (next === 0x5b) {
|
|
212
|
+
i += 2;
|
|
213
|
+
while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
|
|
214
|
+
while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) i++;
|
|
215
|
+
if (i < str.length) i++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const cp = str.codePointAt(i)!;
|
|
220
|
+
const char = cp > 0xffff ? str.slice(i, i + 2) : str[i];
|
|
221
|
+
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
|
|
222
|
+
width += 1;
|
|
223
|
+
} else if (EMOJI_RE.test(char)) {
|
|
224
|
+
width += 2;
|
|
225
|
+
} else if (AMBIGUOUS_WIDE.has(char)) {
|
|
226
|
+
width += 2;
|
|
227
|
+
} else {
|
|
228
|
+
width += 1;
|
|
229
|
+
}
|
|
230
|
+
i += cp > 0xffff ? 2 : 1;
|
|
231
|
+
}
|
|
232
|
+
return width;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Cut a (possibly ANSI-containing) string to fit maxCols visible columns. */
|
|
236
|
+
export function truncateAnsi(str: string, maxCols: number): string {
|
|
237
|
+
if (maxCols <= 0) return "";
|
|
238
|
+
if (termVisWidth(str) <= maxCols) return str;
|
|
239
|
+
let result = "";
|
|
240
|
+
let visWidth = 0;
|
|
241
|
+
let i = 0;
|
|
242
|
+
const target = maxCols - 1;
|
|
243
|
+
while (i < str.length) {
|
|
244
|
+
const code = str.charCodeAt(i);
|
|
245
|
+
if (code === 0x1b && i + 1 < str.length && str.charCodeAt(i + 1) === 0x5b) {
|
|
246
|
+
const start = i;
|
|
247
|
+
i += 2;
|
|
248
|
+
while (i < str.length && str.charCodeAt(i) >= 0x20 && str.charCodeAt(i) <= 0x3f) i++;
|
|
249
|
+
while (i < str.length && str.charCodeAt(i) >= 0x30 && str.charCodeAt(i) <= 0x3f) i++;
|
|
250
|
+
if (i < str.length) i++;
|
|
251
|
+
result += str.slice(start, i);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const cp = str.codePointAt(i)!;
|
|
255
|
+
const char = cp > 0xffff ? str.slice(i, i + 2) : str[i];
|
|
256
|
+
let charWidth: number;
|
|
257
|
+
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) charWidth = 1;
|
|
258
|
+
else if (EMOJI_RE.test(char)) charWidth = 2;
|
|
259
|
+
else if (AMBIGUOUS_WIDE.has(char)) charWidth = 2;
|
|
260
|
+
else charWidth = 1;
|
|
261
|
+
if (visWidth + charWidth > target) break;
|
|
262
|
+
result += char;
|
|
263
|
+
visWidth += charWidth;
|
|
264
|
+
i += cp > 0xffff ? 2 : 1;
|
|
265
|
+
}
|
|
266
|
+
return result + "…";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Widget component ─────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
export interface LineTheme {
|
|
272
|
+
fg(color: string, text: string): string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Width-aware two-zone line. Left (session) is preserved verbatim and
|
|
277
|
+
* truncated only if it alone exceeds the terminal width. Right (account)
|
|
278
|
+
* selects progressively more compact tiers as space tightens; when no tier
|
|
279
|
+
* fits, the line degrades to left-only. The right side flips to the theme's
|
|
280
|
+
* warning color while the balance is at/below the configured threshold.
|
|
281
|
+
*/
|
|
282
|
+
export class StatusLineWidget {
|
|
283
|
+
private theme: LineTheme;
|
|
284
|
+
private leftRaw: string;
|
|
285
|
+
private rightTiers: string[];
|
|
286
|
+
private rightWarn: boolean;
|
|
287
|
+
|
|
288
|
+
constructor(theme: LineTheme, leftRaw: string, rightTiers: string[] = [], rightWarn = false) {
|
|
289
|
+
this.theme = theme;
|
|
290
|
+
this.leftRaw = leftRaw;
|
|
291
|
+
this.rightTiers = rightTiers;
|
|
292
|
+
this.rightWarn = rightWarn;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
invalidate(): void {}
|
|
296
|
+
|
|
297
|
+
render(width: number): string[] {
|
|
298
|
+
const leftVis = termVisWidth(this.leftRaw);
|
|
299
|
+
if (leftVis > width) {
|
|
300
|
+
return [this.theme.fg("dim", truncateAnsi(this.leftRaw, width))];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const rightColor = this.rightWarn ? "warning" : "dim";
|
|
304
|
+
const themedLeft = this.theme.fg("dim", this.leftRaw);
|
|
305
|
+
const budget = width - leftVis - 1;
|
|
306
|
+
|
|
307
|
+
for (const tier of this.rightTiers) {
|
|
308
|
+
if (termVisWidth(tier) <= budget) {
|
|
309
|
+
const themedRight = this.theme.fg(rightColor, tier);
|
|
310
|
+
const pad = width - termVisWidth(themedLeft) - termVisWidth(themedRight);
|
|
311
|
+
return [themedLeft + " ".repeat(Math.max(1, pad)) + themedRight];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const pad = width - termVisWidth(themedLeft);
|
|
316
|
+
return [themedLeft + " ".repeat(Math.max(0, pad))];
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
formatBalHc,
|
|
17
|
+
formatRateCompact,
|
|
18
|
+
formatSpendHc,
|
|
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(formatBalHc(249), "249");
|
|
29
|
+
assert.equal(formatBalHc(12345), "12.3k");
|
|
30
|
+
assert.equal(formatBalHc(250.5), "250.5");
|
|
31
|
+
assert.equal(formatBalHc(1_250_000), "1.25M");
|
|
32
|
+
assert.equal(formatSpendHc(0), "0");
|
|
33
|
+
assert.equal(formatSpendHc(0.0004), "~0");
|
|
34
|
+
assert.equal(formatSpendHc(0.0021), "0.0021");
|
|
35
|
+
assert.equal(formatSpendHc(0.31), "0.31");
|
|
36
|
+
assert.equal(formatSpendHc(12.5), "12.5");
|
|
37
|
+
assert.equal(formatRateCompact(996), "996");
|
|
38
|
+
assert.equal(formatRateCompact(1000), "1k");
|
|
39
|
+
assert.equal(formatRateCompact(9996), "10k");
|
|
40
|
+
|
|
41
|
+
// ── session line ──
|
|
42
|
+
assert.equal(buildSessionLine({ requests: 0, spendHc: 0 }), undefined);
|
|
43
|
+
assert.equal(buildSessionLine({ requests: 7, spendHc: 1.24 }), "⚡ 1.24 hc · 7 req");
|
|
44
|
+
assert.equal(buildSessionLine({ requests: 1, spendHc: 0 }), "⚡ 0 hc · 1 req");
|
|
45
|
+
|
|
46
|
+
// ── account tiers ──
|
|
47
|
+
const acc = (over: Partial<AccountState>): AccountState => ({ ...EMPTY_ACCOUNT, ...over });
|
|
48
|
+
const rate = { limitHour: 1000, limitDay: 10000, remainingHour: 996, remainingDay: 9996, capturedAt: 0 };
|
|
49
|
+
|
|
50
|
+
assert.equal(accountHasData(acc({})), false);
|
|
51
|
+
assert.equal(accountHasData(acc({ balance: 0 })), true);
|
|
52
|
+
|
|
53
|
+
const full = acc({ balance: 249, teamName: "ACME Team", rate, authDaysLeft: 29 });
|
|
54
|
+
const tiers = buildAccountTiers(full, false);
|
|
55
|
+
assert.equal(tiers[0], "ACME Team ◆ 249 hc · 996/1k/h · 10k/10k/d · ⟳ 29d");
|
|
56
|
+
assert.ok(tiers.includes("ACME Team ◆ 249 hc"));
|
|
57
|
+
assert.ok(tiers.includes("◆ 249 hc · 996/1k/h"));
|
|
58
|
+
assert.ok(tiers.includes("249 hc"));
|
|
59
|
+
// tiers must be strictly non-increasing in width
|
|
60
|
+
for (let i = 1; i < tiers.length; i++) {
|
|
61
|
+
assert.ok(termVisWidth(tiers[i]) <= termVisWidth(tiers[i - 1]), `tier ${i} wider than previous`);
|
|
62
|
+
}
|
|
63
|
+
// dedupe of adjacent identical tiers when atoms are missing
|
|
64
|
+
assert.deepEqual(buildAccountTiers(acc({ teamName: "ACME" }), false), ["ACME"]);
|
|
65
|
+
const balOnly = buildAccountTiers(acc({ balance: 12 }), true);
|
|
66
|
+
assert.equal(balOnly[0], "⚠ ◆ 12 hc");
|
|
67
|
+
assert.ok(balOnly.includes("12 hc"));
|
|
68
|
+
|
|
69
|
+
// optimistic spend deduction
|
|
70
|
+
{
|
|
71
|
+
const opt = acc({ balance: 249 });
|
|
72
|
+
applyOptimisticSpend(opt, 0.5);
|
|
73
|
+
assert.equal(opt.balance, 248.5);
|
|
74
|
+
applyOptimisticSpend(opt, 0); // zero spend is a no-op
|
|
75
|
+
assert.equal(opt.balance, 248.5);
|
|
76
|
+
applyOptimisticSpend(opt, 300); // clamps at 0, never negative
|
|
77
|
+
assert.equal(opt.balance, 0);
|
|
78
|
+
const unknown = acc({ balance: null });
|
|
79
|
+
applyOptimisticSpend(unknown, 1); // unknown balance stays unknown
|
|
80
|
+
assert.equal(unknown.balance, null);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── width math ──
|
|
84
|
+
assert.equal(termVisWidth("abc"), 3);
|
|
85
|
+
assert.equal(termVisWidth(""), 0);
|
|
86
|
+
assert.equal(termVisWidth(fakeTheme.fg("dim", "abc")), 3, "ANSI is zero-width");
|
|
87
|
+
assert.equal(termVisWidth("◆"), 1, "◆ counts as narrow in this terminal");
|
|
88
|
+
assert.equal(termVisWidth("⚡"), 2);
|
|
89
|
+
assert.equal(truncateAnsi("hello world", 8), "hello w…");
|
|
90
|
+
assert.equal(termVisWidth(truncateAnsi(fakeTheme.fg("x", "hello world"), 8)), 8);
|
|
91
|
+
assert.equal(truncateAnsi("abc", 5), "abc");
|
|
92
|
+
assert.equal(truncateAnsi("abc", 0), "");
|
|
93
|
+
|
|
94
|
+
// ── widget render ──
|
|
95
|
+
const left = buildSessionLine({ requests: 7, spendHc: 1.24 })!;
|
|
96
|
+
const widget = new StatusLineWidget(fakeTheme, left, tiers, false);
|
|
97
|
+
|
|
98
|
+
// Wide: full tier, left-right justified, exactly width columns
|
|
99
|
+
const wide = widget.render(80);
|
|
100
|
+
assert.equal(wide.length, 1);
|
|
101
|
+
assert.equal(termVisWidth(wide[0]), 80);
|
|
102
|
+
assert.ok(stripAnsi(wide[0]).startsWith("⚡ 1.24 hc"));
|
|
103
|
+
assert.ok(stripAnsi(wide[0]).endsWith("⟳ 29d"));
|
|
104
|
+
|
|
105
|
+
// Medium: drops to a compressed tier, still exactly width
|
|
106
|
+
const med = widget.render(52);
|
|
107
|
+
assert.equal(termVisWidth(med[0]), 52);
|
|
108
|
+
assert.ok(!stripAnsi(med[0]).includes("⟳"), "compressed tiers drop auth first");
|
|
109
|
+
|
|
110
|
+
// Narrow: no tier fits → left only, padded
|
|
111
|
+
const narrow = widget.render(termVisWidth(left) + 3);
|
|
112
|
+
assert.equal(termVisWidth(narrow[0]), termVisWidth(left) + 3);
|
|
113
|
+
assert.ok(stripAnsi(narrow[0]).startsWith("⚡"));
|
|
114
|
+
assert.ok(!stripAnsi(narrow[0]).includes("◆"));
|
|
115
|
+
|
|
116
|
+
// Narrower than left itself: truncation never overflows (crash guard)
|
|
117
|
+
const tiny = widget.render(10);
|
|
118
|
+
assert.equal(termVisWidth(tiny[0]), 10);
|
|
119
|
+
|
|
120
|
+
// Left empty (session gated) → right-aligned account line
|
|
121
|
+
const rightOnly = new StatusLineWidget(fakeTheme, "", tiers, false);
|
|
122
|
+
const ro = rightOnly.render(70);
|
|
123
|
+
assert.equal(termVisWidth(ro[0]), 70);
|
|
124
|
+
assert.ok(stripAnsi(ro[0]).endsWith("⟳ 29d"));
|
|
125
|
+
|
|
126
|
+
// No data at all
|
|
127
|
+
assert.deepEqual(new StatusLineWidget(fakeTheme, "", [], false).render(40), [fakeTheme.fg("dim", "") + " ".repeat(40)]);
|
|
128
|
+
|
|
129
|
+
// Warning color wired through
|
|
130
|
+
const warn = new StatusLineWidget(fakeTheme, "", buildAccountTiers(acc({ balance: 10 }), true), true);
|
|
131
|
+
const warnLine = warn.render(60)[0];
|
|
132
|
+
assert.ok(stripAnsi(warnLine).includes("⚠ ◆ 10 hc"));
|
|
133
|
+
assert.ok(warnLine.includes("warning") || true); // fakeTheme ignores color names
|
|
134
|
+
const markTheme = { fg: (c: string, t: string) => `<${c}>${t}</>` };
|
|
135
|
+
assert.ok(new StatusLineWidget(markTheme, "", buildAccountTiers(acc({ balance: 10 }), true), true).render(60)[0].includes("<warning>"));
|
|
136
|
+
assert.ok(new StatusLineWidget(markTheme, "", buildAccountTiers(acc({ balance: 10 }), true), false).render(60)[0].includes("<dim>"));
|
|
137
|
+
|
|
138
|
+
// ── config coercion ──
|
|
139
|
+
assert.deepEqual(coerceStatusConfig(undefined), {
|
|
140
|
+
session: "widget",
|
|
141
|
+
account: "widget",
|
|
142
|
+
hideOnOtherProvider: true,
|
|
143
|
+
lowBalanceHc: 25,
|
|
144
|
+
});
|
|
145
|
+
assert.deepEqual(coerceStatusConfig({ session: "bogus", lowBalanceHc: -3 }), {
|
|
146
|
+
session: "widget",
|
|
147
|
+
account: "widget",
|
|
148
|
+
hideOnOtherProvider: true,
|
|
149
|
+
lowBalanceHc: 25,
|
|
150
|
+
});
|
|
151
|
+
assert.deepEqual(coerceStatusConfig({ session: "statusbar", account: "off", hideOnOtherProvider: false, lowBalanceHc: null }), {
|
|
152
|
+
session: "statusbar",
|
|
153
|
+
account: "off",
|
|
154
|
+
hideOnOtherProvider: false,
|
|
155
|
+
lowBalanceHc: null,
|
|
156
|
+
});
|
|
157
|
+
assert.equal(coerceStatusConfig({ lowBalanceHc: 42 }).lowBalanceHc, 42);
|
|
158
|
+
assert.equal(coerceStatusConfig({ lowBalanceHc: false }).lowBalanceHc, null);
|
|
159
|
+
assert.deepEqual(coerceStatusConfig(null).session, "widget");
|
|
160
|
+
|
|
161
|
+
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
|
+
}
|