@genee/omp-opsx-addon 0.1.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/README.md +83 -0
- package/index.ts +1327 -0
- package/lib/agent-defs.ts +291 -0
- package/lib/change-scanner.ts +72 -0
- package/lib/cursor-auth.ts +230 -0
- package/lib/direct-fetchers.ts +155 -0
- package/lib/edit-variant-pin.ts +156 -0
- package/lib/error-scan.ts +97 -0
- package/lib/family-filter.ts +174 -0
- package/lib/model-selector.ts +307 -0
- package/lib/model-tiers.ts +164 -0
- package/lib/notify.ts +4 -0
- package/lib/openspec-tasks.ts +28 -0
- package/lib/peak-detector.ts +44 -0
- package/lib/reachability-cache.ts +288 -0
- package/lib/reachability-probe.ts +459 -0
- package/lib/reachability-refresh.ts +84 -0
- package/lib/selection-filters.ts +100 -0
- package/lib/selection-lifecycle.ts +17 -0
- package/lib/session-title.ts +186 -0
- package/lib/session-usage.ts +252 -0
- package/lib/state.ts +95 -0
- package/lib/system-prompt.ts +160 -0
- package/lib/tiers-data.ts +136 -0
- package/lib/tiers-updater.ts +146 -0
- package/lib/unified-config.ts +463 -0
- package/lib/usage-estimator.ts +154 -0
- package/lib/usage-poller.ts +1200 -0
- package/lib/usage-poller.worker.ts +31 -0
- package/lib/usage-redis-client.ts +594 -0
- package/lib/usage-redis-multiproc.worker.ts +80 -0
- package/lib/usage-redis-probe.ts +389 -0
- package/lib/usage-render.ts +526 -0
- package/lib/usage-resolver.ts +477 -0
- package/lib/usage-sampler.ts +77 -0
- package/lib/usage-widget.ts +112 -0
- package/package.json +47 -0
- package/types/bun.d.ts +21 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { visibleWidth, truncateToWidth } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import { resolveUsedFraction, getProviderDefinition, type UsageLimit, type UsageReport } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { summarizeReport, quotaPolicyFor } from "./usage-resolver.js";
|
|
4
|
+
|
|
5
|
+
// Hardcoded ANSI retained for `ansiPainter` — the RPC/print fallback path and
|
|
6
|
+
// the legacy `render.test.ts` baseline. The interactive component-factory path
|
|
7
|
+
// swaps them for the host theme via `themePainter(theme)`.
|
|
8
|
+
const R = "\x1b[0m", G = "\x1b[32m", Y = "\x1b[33m", X = "\x1b[31m", D = "\x1b[2m";
|
|
9
|
+
const BLUE = "\x1b[34m", PURPLE = "\x1b[35m", CYAN = "\x1b[36m", WHITE = "\x1b[37m";
|
|
10
|
+
const BOLD = "\x1b[1m";
|
|
11
|
+
const c = (s: string, t: string): string => `${s}${t}${R}`;
|
|
12
|
+
|
|
13
|
+
// The subset of the host `ThemeColor` union the widget actually paints with.
|
|
14
|
+
// Keeping it narrow lets widget tests inject a mock theme that only implements
|
|
15
|
+
// `fg` instead of the full `Theme` surface (the host passes a real `Theme`,
|
|
16
|
+
// which is structurally assignable thanks to `PainterColor ⊆ ThemeColor`).
|
|
17
|
+
export type PainterColor = "success" | "warning" | "error" | "dim" | "accent" | "text";
|
|
18
|
+
|
|
19
|
+
/** Minimal theme surface the rendering painter consumes (`host Theme.fg`). */
|
|
20
|
+
export interface PainterTheme {
|
|
21
|
+
fg(color: PainterColor, text: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decouples "which ANSI/theme color wraps a span" from the validated flex-wrap
|
|
26
|
+
* layout brain. `ansiPainter` reproduces the pre-refactor hardcoded ANSI; a
|
|
27
|
+
* `themePainter(theme)` routes every status color through the host theme so the
|
|
28
|
+
* widget follows the active palette. Provider brand identity colors stay raw
|
|
29
|
+
* ANSI under both painters (the host theme exposes no arbitrary-hue token — see
|
|
30
|
+
* the design trade-off), using `accent` only as the header fallback when a
|
|
31
|
+
* provider has no curated brand color.
|
|
32
|
+
*/
|
|
33
|
+
export interface Painter {
|
|
34
|
+
/** Used-fraction percentage, threshold-colored. */
|
|
35
|
+
pct(p: number): string;
|
|
36
|
+
/** Exhausted-window marker text, error-colored. */
|
|
37
|
+
exhausted(text: string): string;
|
|
38
|
+
/** Inter-column separator, already dyed incl. flanking spaces (3 cells). */
|
|
39
|
+
sep: string;
|
|
40
|
+
/** Provider header. `brandAnsi` is a raw brand-color SGR (or "" when none). */
|
|
41
|
+
header(label: string, brandAnsi: string): string;
|
|
42
|
+
/** Numeric balance text (e.g. "余额 ¥18.06"), text-colored. */
|
|
43
|
+
balance(text: string): string;
|
|
44
|
+
/** Foreground-color an arbitrary span with a named color (waveform cells). */
|
|
45
|
+
fg(color: PainterColor, text: string): string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pctValue = (p: number): string => `${Math.round(p * 100)}%`;
|
|
49
|
+
const pctColor = (p: number): PainterColor => (p >= 0.85 ? "error" : p >= 0.6 ? "warning" : "success");
|
|
50
|
+
|
|
51
|
+
/** Pre-refactor hardcoded ANSI — the RPC/print fallback and the test baseline. */
|
|
52
|
+
export const ansiPainter: Painter = {
|
|
53
|
+
pct: (p) => {
|
|
54
|
+
const col = pctColor(p);
|
|
55
|
+
return c(col === "error" ? X : col === "warning" ? Y : G, pctValue(p));
|
|
56
|
+
},
|
|
57
|
+
exhausted: (text) => c(X, text),
|
|
58
|
+
sep: ` ${D}│${R} `,
|
|
59
|
+
header: (label, brandAnsi) => c(brandAnsi ? BOLD + brandAnsi : BOLD, label),
|
|
60
|
+
balance: (text) => c(WHITE, text),
|
|
61
|
+
fg: (color, text) => c(color === "error" ? X : color === "warning" ? Y : color === "success" ? G : color === "dim" ? D : color === "accent" ? CYAN : WHITE, text),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Status colors via the active host theme; brand headers keep raw ANSI. */
|
|
65
|
+
export function themePainter(theme: PainterTheme): Painter {
|
|
66
|
+
return {
|
|
67
|
+
pct: (p) => theme.fg(pctColor(p), pctValue(p)),
|
|
68
|
+
exhausted: (text) => theme.fg("error", text),
|
|
69
|
+
sep: ` ${theme.fg("dim", "│")} `,
|
|
70
|
+
header: (label, brandAnsi) => (brandAnsi ? c(brandAnsi, label) : theme.fg("accent", label)),
|
|
71
|
+
balance: (text) => theme.fg("text", text),
|
|
72
|
+
fg: (color, text) => theme.fg(color, text),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Curated short labels + brand colors for providers whose registry `name` is too
|
|
77
|
+
// verbose for a compact status widget (e.g. "Z.AI (GLM Coding Plan · Sign in)").
|
|
78
|
+
// Unknown providers fall back to the omp registry display name, then the raw id.
|
|
79
|
+
const PROVIDER: Record<string, { label: string; color: string }> = {
|
|
80
|
+
"zhipu-coding-plan": { label: "智谱", color: PURPLE },
|
|
81
|
+
zai: { label: "智谱", color: PURPLE },
|
|
82
|
+
"kimi-code": { label: "Kimi", color: CYAN },
|
|
83
|
+
"minimax-code-cn": { label: "MiniMax", color: X },
|
|
84
|
+
deepseek: { label: "DeepSeek",color: BLUE },
|
|
85
|
+
cursor: { label: "Cursor", color: CYAN },
|
|
86
|
+
"opencode-go": { label: "OpenCode",color: G },
|
|
87
|
+
moonshot: { label: "Moonshot",color: G },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const providerLabel = (id: string): { label: string; color: string } => {
|
|
91
|
+
const known = PROVIDER[id];
|
|
92
|
+
if (known) return known;
|
|
93
|
+
const reg = getProviderDefinition(id);
|
|
94
|
+
if (reg?.name) return { label: reg.name, color: "" };
|
|
95
|
+
return { label: id, color: "" };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const WL: Record<string, string> = { "5h": "5h", "rolling-5h": "5h", "1d": "日", "1w": "周", weekly: "周", "1mo": "月", monthly: "月" };
|
|
99
|
+
|
|
100
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
const pad2 = (n: number): string => String(n).padStart(2, "0");
|
|
103
|
+
|
|
104
|
+
/** Fixed-width reset countdown, e.g. "00d03h12m05s"; "即将重置" once elapsed. */
|
|
105
|
+
const resetIn = (resetsAt: number): string => {
|
|
106
|
+
const diffMs = resetsAt - Date.now();
|
|
107
|
+
if (diffMs <= 0) return "即将重置";
|
|
108
|
+
const totalSec = Math.floor(diffMs / 1_000);
|
|
109
|
+
const d = Math.floor(totalSec / 86_400);
|
|
110
|
+
const h = Math.floor((totalSec % 86_400) / 3_600);
|
|
111
|
+
const m = Math.floor((totalSec % 3_600) / 60);
|
|
112
|
+
const s = totalSec % 60;
|
|
113
|
+
return `${pad2(d)}d${pad2(h)}h${pad2(m)}m${pad2(s)}s`;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function isQuotaFull(l: UsageLimit): boolean {
|
|
117
|
+
if (l.status === "exhausted") return true;
|
|
118
|
+
const pct = resolveUsedFraction(l);
|
|
119
|
+
return typeof pct === "number" && pct >= 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resetAtOf(l: UsageLimit, report?: UsageReport): number | undefined {
|
|
123
|
+
if (l.window?.resetsAt) return l.window.resetsAt;
|
|
124
|
+
return report?.limits.find((x) => x.window?.resetsAt)?.window?.resetsAt;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Exhausted / 100% text (uncolored); the painter wraps it in the error color. */
|
|
128
|
+
const exhaustedText = (l: UsageLimit, report?: UsageReport): string => {
|
|
129
|
+
const at = resetAtOf(l, report);
|
|
130
|
+
return at !== undefined ? resetIn(at) : "耗尽";
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** Recovery countdown when the provider is unusable.
|
|
134
|
+
* - conjunctive / balance (智谱、OpenCode、DeepSeek…): latest reset — full
|
|
135
|
+
* recovery waits for the last blocking window.
|
|
136
|
+
* - bucket (Cursor): soonest reset — any revived bucket unblocks usage.
|
|
137
|
+
* Falls back to any sibling resetsAt / `耗尽` when no exhausted window has one.
|
|
138
|
+
*/
|
|
139
|
+
const providerExhaustedText = (report: UsageReport): string => {
|
|
140
|
+
const preferLatest = quotaPolicyFor(report.provider) !== "bucket";
|
|
141
|
+
const pick = (a: number, b: number): number => (preferLatest ? Math.max(a, b) : Math.min(a, b));
|
|
142
|
+
|
|
143
|
+
let chosen: number | undefined;
|
|
144
|
+
// Prefer each exhausted window's own resetsAt — do NOT inherit a sibling's
|
|
145
|
+
// timestamp here, or "latest/soonest among dead windows" gets skewed.
|
|
146
|
+
for (const l of report.limits) {
|
|
147
|
+
if (!isQuotaFull(l)) continue;
|
|
148
|
+
const at = l.window?.resetsAt;
|
|
149
|
+
if (typeof at !== "number") continue;
|
|
150
|
+
chosen = chosen === undefined ? at : pick(chosen, at);
|
|
151
|
+
}
|
|
152
|
+
if (chosen === undefined) {
|
|
153
|
+
for (const l of report.limits) {
|
|
154
|
+
const at = l.window?.resetsAt;
|
|
155
|
+
if (typeof at !== "number") continue;
|
|
156
|
+
chosen = chosen === undefined ? at : pick(chosen, at);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return chosen !== undefined ? resetIn(chosen) : "耗尽";
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const cursorWinLabel = (l: UsageLimit): string | undefined => {
|
|
163
|
+
const hay = `${l.id ?? ""} ${l.label ?? ""} ${l.window?.id ?? ""}`.toLowerCase();
|
|
164
|
+
if (hay.includes("ondemand") || hay.includes("on-demand") || hay.includes("spendlimit") || hay.includes("按需")) return "按需";
|
|
165
|
+
if (hay.includes("builtin") || hay.includes("cursor models") || hay.includes("planusage") || hay.includes("autopercent") || hay.includes("内置")) return "内置";
|
|
166
|
+
if (hay.includes("other") || hay.includes("named") || hay.includes("apipercent") || hay.includes("其他")) return "其他";
|
|
167
|
+
return undefined;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const winLabel = (l: UsageLimit): string => {
|
|
171
|
+
const cursor = cursorWinLabel(l);
|
|
172
|
+
if (cursor) return cursor;
|
|
173
|
+
const w = l.window;
|
|
174
|
+
if (!w) return l.label || "?";
|
|
175
|
+
if (WL[w.id]) return WL[w.id];
|
|
176
|
+
if (w.durationMs === 18_000_000) return "5h";
|
|
177
|
+
if (w.durationMs === 86_400_000) return "日";
|
|
178
|
+
if (w.durationMs === 604_800_000) return "周";
|
|
179
|
+
if (w.durationMs === 2_592_000_000) return "月";
|
|
180
|
+
if (w.id === "default") return l.label === "Total quota" ? "总" : l.label;
|
|
181
|
+
return w.label || w.id;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const windowSortKey = (l: UsageLimit): number => {
|
|
185
|
+
const cursor = cursorWinLabel(l);
|
|
186
|
+
if (cursor === "内置") return 4;
|
|
187
|
+
if (cursor === "其他") return 5;
|
|
188
|
+
if (cursor === "按需") return 7;
|
|
189
|
+
const w = l.window;
|
|
190
|
+
if (!w) return 50;
|
|
191
|
+
if (w.id === "5h") return 0;
|
|
192
|
+
if (w.id === "1d") return 1;
|
|
193
|
+
if (w.id === "1w") return 2;
|
|
194
|
+
if (w.id === "1mo") return 3;
|
|
195
|
+
// Cursor dashboard order: Cursor Models(内置)→ Other Models(其他)→ On-demand(按需).
|
|
196
|
+
if (w.id === "builtin" || w.id === "auto") return 4;
|
|
197
|
+
if (w.id === "other" || w.id === "api") return 5;
|
|
198
|
+
if (w.id === "ondemand") return 7;
|
|
199
|
+
if (w.durationMs === 18_000_000) return 0;
|
|
200
|
+
if (w.durationMs === 86_400_000) return 1;
|
|
201
|
+
if (w.durationMs === 604_800_000) return 2;
|
|
202
|
+
if (w.durationMs === 2_592_000_000) return 3;
|
|
203
|
+
if (w.id === "default" || l.label === "Total quota") return 100;
|
|
204
|
+
return 50;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Right-align a cell: pad short lines; leave ≥-width lines intact (colWidth = max). */
|
|
208
|
+
const padRight = (s: string, width: number): string => {
|
|
209
|
+
const v = visibleWidth(s);
|
|
210
|
+
if (v >= width) return s;
|
|
211
|
+
return s + " ".repeat(width - v);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ── column builder ───────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Render a single provider as a compact 2-line column:
|
|
218
|
+
* line 0 — provider name
|
|
219
|
+
* line 1 — either horizontal window chips (`5h 0% · 周 70%`) or a single
|
|
220
|
+
* reset countdown when the provider is unusable.
|
|
221
|
+
*
|
|
222
|
+
* Exhaustion follows `summarizeReport` policy:
|
|
223
|
+
* - conjunctive (智谱 / Kimi / MiniMax / OpenCode): any required window dead
|
|
224
|
+
* → whole body becomes the latest exhausted-window reset countdown
|
|
225
|
+
* - bucket (Cursor): still usable while any bucket has quota → keep every
|
|
226
|
+
* window chip; dead windows show `100%` without a countdown. Fully dead →
|
|
227
|
+
* soonest reset countdown (any revived bucket unblocks).
|
|
228
|
+
* - balance (DeepSeek): remaining ≤ 0 → countdown / `耗尽`
|
|
229
|
+
*
|
|
230
|
+
* `undefined` when there is nothing to show. Natural widths only —
|
|
231
|
+
* `renderUsageReports` owns multi-column alignment, `ProviderCard` owns
|
|
232
|
+
* per-card truncation.
|
|
233
|
+
*/
|
|
234
|
+
export const buildColumn = (r: UsageReport, painter: Painter = ansiPainter, loading = false): string[] | undefined => {
|
|
235
|
+
if (r.limits.length === 0) return;
|
|
236
|
+
const { label, color } = providerLabel(r.provider);
|
|
237
|
+
const header = painter.header(loading ? `${label}⟳` : label, color);
|
|
238
|
+
const health = summarizeReport(r);
|
|
239
|
+
|
|
240
|
+
// Balance / non-window providers (DeepSeek CNY, etc.).
|
|
241
|
+
if (!r.limits.some((l) => Boolean(l.window?.id))) {
|
|
242
|
+
const l = r.limits[0];
|
|
243
|
+
let val: string;
|
|
244
|
+
if (health.exhausted || isQuotaFull(l)) {
|
|
245
|
+
val = painter.exhausted(exhaustedText(l, r));
|
|
246
|
+
} else if (typeof l.amount.remaining === "number") {
|
|
247
|
+
const sym = (l.amount.unit as string) === "CNY" ? "¥" : "$";
|
|
248
|
+
const text = r.provider === "deepseek" ? `余额 ${sym}${l.amount.remaining}` : `${sym}${l.amount.remaining}`;
|
|
249
|
+
val = painter.balance(text);
|
|
250
|
+
} else {
|
|
251
|
+
const pct = resolveUsedFraction(l);
|
|
252
|
+
val = typeof pct === "number" ? painter.pct(pct) : "?";
|
|
253
|
+
}
|
|
254
|
+
return [header, val];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Provider-level dead → single countdown (no mixed % chips).
|
|
258
|
+
if (health.exhausted) {
|
|
259
|
+
return [header, painter.exhausted(providerExhaustedText(r))];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Still usable → horizontal chips. Dead windows keep a % / 耗尽 marker
|
|
263
|
+
// (Cursor partial exhaustion) but never a reset countdown.
|
|
264
|
+
// Cursor: only show 内置/其他/按需. Unclassified monthly leftovers
|
|
265
|
+
// (Personal Usage / legacy request-count buckets that fall back to "月")
|
|
266
|
+
// are noise and are hidden.
|
|
267
|
+
const sorted = [...r.limits].sort((a, b) => windowSortKey(a) - windowSortKey(b));
|
|
268
|
+
const parts: string[] = [];
|
|
269
|
+
const isCursor = r.provider === "cursor";
|
|
270
|
+
for (const l of sorted) {
|
|
271
|
+
if (isCursor && !cursorWinLabel(l)) continue;
|
|
272
|
+
const wl = winLabel(l);
|
|
273
|
+
let val: string;
|
|
274
|
+
if (isQuotaFull(l)) {
|
|
275
|
+
const pct = resolveUsedFraction(l);
|
|
276
|
+
val = typeof pct === "number" ? painter.pct(Math.min(pct, 1)) : painter.exhausted("耗尽");
|
|
277
|
+
} else {
|
|
278
|
+
const pct = resolveUsedFraction(l);
|
|
279
|
+
val = typeof pct === "number" ? painter.pct(pct) : "?";
|
|
280
|
+
}
|
|
281
|
+
parts.push(`${wl} ${val}`);
|
|
282
|
+
}
|
|
283
|
+
if (parts.length === 0) {
|
|
284
|
+
// e.g. Cursor only returned unclassified monthly leftovers — keep the
|
|
285
|
+
// column visible rather than dropping a logged-in provider.
|
|
286
|
+
return isCursor ? [header, painter.balance("—")] : undefined;
|
|
287
|
+
}
|
|
288
|
+
return [header, parts.join(" · ")];
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Render a logged-in provider that has no report yet as a header + a single
|
|
293
|
+
* placeholder row (`—`). Placeholder identity is always carried by an explicit id
|
|
294
|
+
* (see `renderUsageReports` `placeholderIds`); it is NEVER inferred from an empty
|
|
295
|
+
* `limits` array, so a real fetch that returns no windows is not mistaken for a
|
|
296
|
+
* logged-in-but-unfetched provider.
|
|
297
|
+
*/
|
|
298
|
+
export const buildPlaceholderColumn = (provider: string, painter: Painter = ansiPainter, loading = false): string[] => {
|
|
299
|
+
const { label, color } = providerLabel(provider);
|
|
300
|
+
// Loading placeholder: body shows ⟳ (fetch in flight) instead of the idle —.
|
|
301
|
+
return [painter.header(loading ? `${label}⟳` : label, color), painter.balance(loading ? "⟳" : "—")];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── flex-wrap table layout ───────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
const SEP_VISUAL = 3;
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
export function renderUsageReports(
|
|
310
|
+
reports: UsageReport[],
|
|
311
|
+
maxWidth = 120,
|
|
312
|
+
painter: Painter = ansiPainter,
|
|
313
|
+
placeholderIds?: readonly string[],
|
|
314
|
+
loadingProviders?: ReadonlySet<string>,
|
|
315
|
+
consumptionTracks?: Map<string, ConsumptionTrack>,
|
|
316
|
+
usageEstimates?: Map<string, string>,
|
|
317
|
+
): string[] {
|
|
318
|
+
// Placeholder providers are logged-in but unfetched this round. Their ids are
|
|
319
|
+
// explicit (never inferred from `limits.length === 0`); dedupe against reports
|
|
320
|
+
// so a provider present in both doesn't render twice.
|
|
321
|
+
const reportedIds = new Set((reports ?? []).map((r) => r.provider));
|
|
322
|
+
const placeholders = (placeholderIds ?? []).filter((id) => !reportedIds.has(id));
|
|
323
|
+
if (!reports?.length && placeholders.length === 0) return [];
|
|
324
|
+
const loading = loadingProviders ?? new Set<string>();
|
|
325
|
+
|
|
326
|
+
// Build each provider column (header + usage / placeholder). Waveforms are
|
|
327
|
+
// appended AFTER the uniform block width is known so every provider's
|
|
328
|
+
// mini chart is the same width.
|
|
329
|
+
interface ProviderColumn { provider: string; col: string[] }
|
|
330
|
+
const cols: ProviderColumn[] = (reports ?? []).map((r) => {
|
|
331
|
+
const col = r.limits.length === 0
|
|
332
|
+
? buildPlaceholderColumn(r.provider, painter, loading.has(r.provider))
|
|
333
|
+
: buildColumn(r, painter, loading.has(r.provider));
|
|
334
|
+
if (!col) return { provider: r.provider, col: [] };
|
|
335
|
+
// Second-level estimate override replaces the usage line (balance style).
|
|
336
|
+
const est = usageEstimates?.get(r.provider);
|
|
337
|
+
if (est && col.length >= 2) col[1] = painter.balance(est);
|
|
338
|
+
return { provider: r.provider, col };
|
|
339
|
+
});
|
|
340
|
+
for (const id of placeholders) {
|
|
341
|
+
cols.push({ provider: id, col: buildPlaceholderColumn(id, painter, loading.has(id)) });
|
|
342
|
+
}
|
|
343
|
+
const valid = cols.filter((c) => c.col.length > 0);
|
|
344
|
+
if (valid.length === 0) return [];
|
|
345
|
+
|
|
346
|
+
// Uniform block width: every provider block is padded to the widest column,
|
|
347
|
+
// so usage columns align in a clean grid (no ragged per-provider widths).
|
|
348
|
+
const natural = valid.map((c) => Math.max(...c.col.map((l) => visibleWidth(l))));
|
|
349
|
+
const blockWidth = Math.max(...natural);
|
|
350
|
+
const waveCols = Math.min(WAVEFORM_COLS, blockWidth);
|
|
351
|
+
|
|
352
|
+
// Multiple providers: pad every row to the uniform block width so the grid
|
|
353
|
+
// aligns (a lone provider keeps its natural width — nothing to align with).
|
|
354
|
+
const pad = valid.length > 1;
|
|
355
|
+
for (const c of valid) {
|
|
356
|
+
// Equal-width per-provider consumption waveform (same char count for
|
|
357
|
+
// all). A track that exists but has no samples renders as a blank
|
|
358
|
+
// placeholder strip, keeping the column height stable.
|
|
359
|
+
if (consumptionTracks) {
|
|
360
|
+
const track = consumptionTracks.get(c.provider);
|
|
361
|
+
if (track) c.col.push(...renderConsumptionLines(track.samples, painter, waveCols));
|
|
362
|
+
}
|
|
363
|
+
if (pad) c.col = c.col.map((l) => padRight(l, blockWidth));
|
|
364
|
+
}
|
|
365
|
+
// Single row, never wrapped: every provider block sits on one line at its
|
|
366
|
+
// natural uniform width — the row extends beyond `maxWidth` rather than
|
|
367
|
+
// squeezing content or breaking to a second line (user preference:
|
|
368
|
+
// roomy over compact).
|
|
369
|
+
const n = Math.max(...valid.map((c) => c.col.length));
|
|
370
|
+
const out: string[] = [];
|
|
371
|
+
for (let line = 0; line < n; line++) {
|
|
372
|
+
const cells = valid.map((c) => {
|
|
373
|
+
const t = line < c.col.length ? c.col[line] : "";
|
|
374
|
+
return pad ? padRight(t, blockWidth) : t;
|
|
375
|
+
});
|
|
376
|
+
out.push(cells.join(painter.sep));
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
// ── token consumption waveform (Braille, design Decision 5) ─────────
|
|
381
|
+
|
|
382
|
+
/** Fixed waveform width in Braille columns (2 px each → 40 px). */
|
|
383
|
+
export const WAVEFORM_COLS = 20;
|
|
384
|
+
|
|
385
|
+
/** Clamp consumption (0..1) to an 8-pixel column height, rounded. */
|
|
386
|
+
export function braillePixelHeight(consumption: number): number {
|
|
387
|
+
const clamped = Math.max(0, Math.min(1, consumption));
|
|
388
|
+
return Math.round(clamped * 8);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Single-column dot bitmask for `rows` pixels lit from the bottom (0..4). */
|
|
392
|
+
const LEFT_DOTS = [0x00, 0x40, 0x44, 0x46, 0x47];
|
|
393
|
+
const RIGHT_DOTS = [0x00, 0x80, 0xA0, 0xB0, 0xB8];
|
|
394
|
+
|
|
395
|
+
export function sideDots(rows: number, side: "left" | "right"): number {
|
|
396
|
+
const r = Math.max(0, Math.min(4, rows));
|
|
397
|
+
return side === "left" ? LEFT_DOTS[r] : RIGHT_DOTS[r];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Compose a Braille cell (U+2800 | left | right) from two column heights. */
|
|
401
|
+
export function composeChar(leftRows: number, rightRows: number): string {
|
|
402
|
+
const code = 0x2800 | sideDots(leftRows, "left") | sideDots(rightRows, "right");
|
|
403
|
+
return String.fromCharCode(code);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Named-color band for a stock consumption level (`1 - remaining`): remaining ≥ 50% → success, ≥ 20% → warning, else error. */
|
|
407
|
+
export function consumptionColor(c: number): PainterColor {
|
|
408
|
+
if (c <= 0.5) return "success";
|
|
409
|
+
if (c <= 0.8) return "warning";
|
|
410
|
+
return "error";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** One provider's consumption track feeding its embedded mini chart. */
|
|
414
|
+
export interface ConsumptionTrack {
|
|
415
|
+
/** Per-interval consumption increments (raw values), newest last. */
|
|
416
|
+
samples: readonly number[];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Render the consumption waveform as a fixed 2-line Braille strip.
|
|
421
|
+
*
|
|
422
|
+
* Samples are per-second consumption INCREMENTS, one per second, aligned
|
|
423
|
+
* 1px-per-sample (right-aligned — newest hugs the right edge, older scroll
|
|
424
|
+
* left). The strip is drawn as a smooth polyline — every pixel's height is a
|
|
425
|
+
* PIECEWISE-LINEAR interpolation of the (right-aligned) sample points,
|
|
426
|
+
* endpoints held — so sparse zero increments connect into a continuous
|
|
427
|
+
* envelope instead of isolated full bars with gaps. Dynamic scale maps the
|
|
428
|
+
* window's largest increment to the full 8-pixel bar; once the window is
|
|
429
|
+
* FULL (≥8 samples), a 3-point weighted moving average smooths single-second
|
|
430
|
+
* zero gaps into the envelope so quiet seconds don't punch blank columns.
|
|
431
|
+
* The strip ALWAYS renders `cols` wide: an empty buffer or an all-zero
|
|
432
|
+
* window yields a blank placeholder strip, never `undefined`, so every
|
|
433
|
+
* provider column keeps a stable chart area and the layout never jumps.
|
|
434
|
+
*
|
|
435
|
+
* Color is bound to RATE INTENSITY (image-scaling blend, not quota severity —
|
|
436
|
+
* quota urgency already reads in the usage text, e.g. "5h 0%", balance). Each
|
|
437
|
+
* Braille character takes the MEAN of its two half-column interpolated
|
|
438
|
+
* intensities, mapped via consumptionColor (low→green, mid→yellow, high→red),
|
|
439
|
+
* so consecutive chars blend smoothly and never hard-switch. The newest column
|
|
440
|
+
* keeps accent.
|
|
441
|
+
*
|
|
442
|
+
* Geometry unchanged: 1px per sample (half-char step), right-aligned; each
|
|
443
|
+
* character merges its two half-column samples with max and renders both
|
|
444
|
+
* columns at that height; bottom 4 pixels on line 2, top 4 on line 1, width
|
|
445
|
+
* cols.
|
|
446
|
+
*/
|
|
447
|
+
export function renderConsumptionLines(
|
|
448
|
+
samples: readonly number[],
|
|
449
|
+
painter: Painter = ansiPainter,
|
|
450
|
+
cols: number = WAVEFORM_COLS,
|
|
451
|
+
): string[] {
|
|
452
|
+
const n = samples.length;
|
|
453
|
+
const totalPx = cols * 2;
|
|
454
|
+
const win = n > totalPx ? samples.slice(n - totalPx) : samples;
|
|
455
|
+
const m = win.length;
|
|
456
|
+
const winMax = Math.max(...(m > 0 ? win : [0]));
|
|
457
|
+
// Blank placeholder strip: no data (or nothing but zeros) still occupies
|
|
458
|
+
// the chart area, so the per-provider column height stays constant.
|
|
459
|
+
if (!(winMax > 0)) {
|
|
460
|
+
const blank = composeChar(0, 0).repeat(cols);
|
|
461
|
+
return [blank, blank];
|
|
462
|
+
}
|
|
463
|
+
// Curve smoothing (window FULL only): a 3-point weighted moving average
|
|
464
|
+
// (1-2-1) softens single-second zero gaps and spikes into a continuous
|
|
465
|
+
// envelope, so seconds with no consumption no longer punch blank columns
|
|
466
|
+
// through the line. Skipped while the window is still filling or too
|
|
467
|
+
// short (≥8 samples) — the live right edge keeps its exact per-second
|
|
468
|
+
// shape during warm-up.
|
|
469
|
+
const plot = m >= totalPx && m >= 8
|
|
470
|
+
? (() => {
|
|
471
|
+
const s = new Array<number>(m);
|
|
472
|
+
s[0] = (win[0] + win[1]) / 2;
|
|
473
|
+
for (let i = 1; i < m - 1; i++) s[i] = (win[i - 1] + 2 * win[i] + win[i + 1]) / 4;
|
|
474
|
+
s[m - 1] = (win[m - 2] + win[m - 1]) / 2;
|
|
475
|
+
return s;
|
|
476
|
+
})()
|
|
477
|
+
: [...win];
|
|
478
|
+
// Dynamic scale over the SMOOTHED window keeps "largest increment = full
|
|
479
|
+
// bar" after averaging trims the raw peak.
|
|
480
|
+
const max = Math.max(...plot);
|
|
481
|
+
const xOf = (i: number): number => (m === 1 ? totalPx - 1 : totalPx - m + i);
|
|
482
|
+
const intensityAt = (p: number): number => {
|
|
483
|
+
if (p < xOf(0)) return 0; // left padding stays empty
|
|
484
|
+
if (p >= xOf(m - 1)) return plot[m - 1] / max;
|
|
485
|
+
let prevX = xOf(0);
|
|
486
|
+
let prevV = plot[0];
|
|
487
|
+
for (let i = 1; i < m; i++) {
|
|
488
|
+
const x = xOf(i);
|
|
489
|
+
if (p <= x) {
|
|
490
|
+
const span = x - prevX || 1;
|
|
491
|
+
const frac = (p - prevX) / span;
|
|
492
|
+
return (prevV * (1 - frac) + plot[i] * frac) / max;
|
|
493
|
+
}
|
|
494
|
+
prevX = x;
|
|
495
|
+
prevV = plot[i];
|
|
496
|
+
}
|
|
497
|
+
return plot[m - 1] / max;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// Each half-column keeps its OWN interpolated height (two samples share one
|
|
501
|
+
// char, possibly at different heights — the pair-into-one-char geometry);
|
|
502
|
+
// only COLOR is blended (terminals can't half-tint a char, so the char
|
|
503
|
+
// takes the mean intensity of its two half-columns).
|
|
504
|
+
const accentChar = Math.floor((totalPx - 1) / 2);
|
|
505
|
+
// Two-line strip: full scale = 8px (2 Braille rows × 4 dot rows). Each
|
|
506
|
+
// half-column keeps its OWN interpolated height; only COLOR is blended
|
|
507
|
+
// (terminals can't half-tint a char, so the char takes the mean intensity).
|
|
508
|
+
const rows = 2;
|
|
509
|
+
const lines = new Array<string>(rows).fill("");
|
|
510
|
+
for (let c = 0; c < cols; c++) {
|
|
511
|
+
const l = intensityAt(2 * c);
|
|
512
|
+
const r = intensityAt(2 * c + 1);
|
|
513
|
+
if (l === 0 && r === 0) {
|
|
514
|
+
const blank = composeChar(0, 0);
|
|
515
|
+
for (let li = 0; li < rows; li++) lines[li] += blank;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const hL = Math.round(8 * l);
|
|
519
|
+
const hR = Math.round(8 * r);
|
|
520
|
+
const color = c === accentChar ? "accent" : consumptionColor((l + r) / 2);
|
|
521
|
+
// Row 0 = top (px 4..8), row 1 = bottom (px 0..4) of each column.
|
|
522
|
+
lines[0] += painter.fg(color, composeChar(Math.max(0, hL - 4), Math.max(0, hR - 4)));
|
|
523
|
+
lines[1] += painter.fg(color, composeChar(Math.min(4, hL), Math.min(4, hR)));
|
|
524
|
+
}
|
|
525
|
+
return lines;
|
|
526
|
+
}
|