@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.
@@ -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
+ }