@danypops/jittor 0.11.0 → 0.12.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.
@@ -1,320 +0,0 @@
1
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import { HUMAN_TEXT_FIELD_MAX_CHARACTERS, USAGE_CHART_HEIGHT, USAGE_MAX_DISTINCT_SCOPES, USAGE_RENDER_MAX_SERIES, USAGE_Y_AXIS_WIDTH } from "../../src/constants.ts";
4
- import {
5
- buildCostGraph,
6
- buildUsageGraph,
7
- resolveUsageWindow,
8
- USAGE_PERIODS,
9
- usagePeriod,
10
- type CostGraph,
11
- type UsageAggregateRow,
12
- type UsageGraph,
13
- type UsagePeriod,
14
- } from "../../src/domain/usage.ts";
15
- import type { UsageBudgetControl } from "./settings.ts";
16
- import type { JittorPanelClient } from "./tui.ts";
17
-
18
- type UsageAction = "period-prev" | "period-next" | "view-next" | "refresh" | "close";
19
- type UsageColor =
20
- | "accent" | "success" | "warning" | "error" | "thinkingText" | "muted" | "dim" | "borderMuted"
21
- | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator";
22
-
23
- export interface UsageTheme {
24
- fg(color: UsageColor, text: string): string;
25
- bold(text: string): string;
26
- }
27
-
28
- /**
29
- * Categorical palette for per-provider/model series. Deliberately excludes "success"/"warning"/
30
- * "error": those already carry a fixed status meaning elsewhere in this same panel (the budget
31
- * threshold line, freshness state), so reusing them for arbitrary model identity would make a
32
- * model's bar segment look like a warning or a failure to a pre-attentive reader — a real
33
- * categorical-color-design pitfall (see e.g. ColorBrewer/Okabe-Ito guidance on qualitative
34
- * palettes: colors for nominal categories should not imply an order, magnitude, or judgment).
35
- *
36
- * Instead this reuses the syntax-highlighting color roles, because theme authors already tune
37
- * those specifically to be simultaneously distinguishable on screen — that is the same design
38
- * problem as a categorical data palette (many hues coexisting in one view that all need to read
39
- * as different from each other). The order below interleaves the hue families syntax themes
40
- * conventionally assign to keyword/function/string/number/type/variable/operator (violet, blue,
41
- * green, orange, teal, cyan, neutral) so the first colors used are spread around the hue wheel
42
- * rather than clustered, which is the standard categorical-palette heuristic for maximizing
43
- * perceptual separation between adjacent categories.
44
- *
45
- * Terminal foreground color is a single channel with a hard ceiling on how many hues stay mutually
46
- * distinguishable (most qualitative-palette guidance caps around 8–12). Once the hue palette is
47
- * exhausted, seriesStyle adds bold as a second, independent visual channel before any exact
48
- * color+weight combination repeats — a standard visualization technique (encode extra categories
49
- * on an additional channel rather than silently reusing an indistinguishable color).
50
- */
51
- const SERIES_HUES: UsageColor[] = [
52
- "accent", "syntaxFunction", "syntaxString", "syntaxNumber",
53
- "syntaxKeyword", "syntaxType", "thinkingText", "syntaxVariable", "syntaxOperator",
54
- ];
55
-
56
- /** Returns a style function for the Nth series: cycles hue, then adds bold once the palette wraps. */
57
- function seriesStyle(index: number, theme: UsageTheme): (text: string) => string {
58
- const hue = SERIES_HUES[index % SERIES_HUES.length]!;
59
- const useBold = Math.floor(index / SERIES_HUES.length) % 2 === 1;
60
- return (text: string) => useBold ? theme.bold(theme.fg(hue, text)) : theme.fg(hue, text);
61
- }
62
-
63
- const PARTIAL_BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
64
-
65
- function compact(value: number): string {
66
- if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(value >= 10_000_000_000 ? 0 : 1)}B`;
67
- if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
68
- if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 || value % 1_000 === 0 ? 0 : 1)}k`;
69
- return String(Math.round(value));
70
- }
71
-
72
- interface RenderableSeries { key: string; provider: string; model: string; total: number }
73
- interface RenderableBucket { start: number; end: number; total: number; series: Record<string, number> }
74
- interface RenderableChart { period: UsagePeriod; start: number; end: number; buckets: RenderableBucket[]; series: RenderableSeries[]; total: number; truncated: boolean }
75
-
76
- function mergeBuckets(buckets: RenderableBucket[], maximum: number): RenderableBucket[] {
77
- if (buckets.length <= maximum) return buckets;
78
- const result: RenderableBucket[] = [];
79
- for (let index = 0; index < maximum; index += 1) {
80
- const from = Math.floor(index * buckets.length / maximum);
81
- const to = Math.max(from + 1, Math.floor((index + 1) * buckets.length / maximum));
82
- const selected = buckets.slice(from, to);
83
- const series: Record<string, number> = {};
84
- for (const bucket of selected) {
85
- for (const [key, value] of Object.entries(bucket.series)) series[key] = (series[key] ?? 0) + value;
86
- }
87
- result.push({
88
- start: selected[0]!.start,
89
- end: selected[selected.length - 1]!.end,
90
- total: selected.reduce((sum, bucket) => sum + bucket.total, 0),
91
- series,
92
- });
93
- }
94
- return result;
95
- }
96
-
97
- function seriesAt(bucket: RenderableBucket, chart: RenderableChart, valueHeight: number): number {
98
- let cumulative = 0;
99
- for (let index = 0; index < chart.series.length; index += 1) {
100
- cumulative += bucket.series[chart.series[index]!.key] ?? 0;
101
- if (valueHeight <= cumulative) return index;
102
- }
103
- return Math.max(0, chart.series.length - 1);
104
- }
105
-
106
- /** Compact USD formatter: full cents below $1k, then the same k/M suffix convention as compact(). */
107
- function formatUsd(value: number): string {
108
- const magnitude = Math.abs(value);
109
- if (magnitude === 0) return "$0";
110
- if (magnitude < 1_000) return `$${value.toFixed(magnitude < 0.01 ? 4 : 2)}`;
111
- if (magnitude < 1_000_000) return `$${(value / 1_000).toFixed(1)}k`;
112
- return `$${(value / 1_000_000).toFixed(1)}M`;
113
- }
114
-
115
- function formatPeriodPoint(value: number, period: UsagePeriod): string {
116
- const date = new Date(value);
117
- if (period === "hourly" || period === "daily") return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
118
- return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
119
- }
120
-
121
- function axisLabels(start: number, end: number, period: UsagePeriod, width: number): string {
122
- const labels = [formatPeriodPoint(start, period), formatPeriodPoint(start + (end - start) / 2, period), formatPeriodPoint(end, period)];
123
- const positions = [0, Math.max(0, Math.floor((width - labels[1]!.length) / 2)), Math.max(0, width - labels[2]!.length)];
124
- const characters = Array.from({ length: width }, () => " ");
125
- for (let labelIndex = 0; labelIndex < labels.length; labelIndex += 1) {
126
- for (let index = 0; index < labels[labelIndex]!.length && positions[labelIndex]! + index < width; index += 1) {
127
- characters[positions[labelIndex]! + index] = labels[labelIndex]![index]!;
128
- }
129
- }
130
- return characters.join("");
131
- }
132
-
133
- function displayIdentity(value: string): string {
134
- return value.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim().slice(0, HUMAN_TEXT_FIELD_MAX_CHARACTERS);
135
- }
136
-
137
- function plainTheme(): UsageTheme {
138
- return { fg: (_color, text) => text, bold: (text) => text };
139
- }
140
-
141
- interface ChartRenderOptions {
142
- title: string;
143
- formatValue: (value: number) => string;
144
- /** Appended after the observed/budget amount, e.g. " tokens"; empty when formatValue already carries a unit prefix like "$". */
145
- unitSuffix: string;
146
- subtitle?: string;
147
- budget?: number;
148
- noDataText: string;
149
- }
150
-
151
- /** Shared cumulative bar-chart renderer behind renderUsageGraph and renderCostGraph. */
152
- function renderChart(chart: RenderableChart, width: number, theme: UsageTheme, options: ChartRenderOptions): string[] {
153
- const { formatValue, unitSuffix } = options;
154
- const safeWidth = Math.max(20, width);
155
- const chartColumns = Math.max(1, Math.floor((safeWidth - USAGE_Y_AXIS_WIDTH - 1) / 2));
156
- const increments = mergeBuckets(chart.buckets, chartColumns);
157
- const runningSeries: Record<string, number> = {};
158
- let runningTotal = 0;
159
- const buckets = increments.map((bucket) => {
160
- runningTotal += bucket.total;
161
- for (const [key, value] of Object.entries(bucket.series)) runningSeries[key] = (runningSeries[key] ?? 0) + value;
162
- return { ...bucket, total: runningTotal, series: { ...runningSeries } };
163
- });
164
- const barStep = buckets.length * 2 <= safeWidth - USAGE_Y_AXIS_WIDTH ? 2 : 1;
165
- const plotWidth = buckets.length * barStep;
166
- const budget = typeof options.budget === "number" && Number.isFinite(options.budget) && options.budget > 0 ? options.budget : undefined;
167
- const maximum = Math.max(chart.total, budget ?? 0);
168
- const observed = chart.truncated ? `at least ${formatValue(chart.total)}` : formatValue(chart.total);
169
- const budgetState = budget === undefined
170
- ? `${observed}${unitSuffix} · budget not configured${chart.truncated ? " · query limit reached" : ""}`
171
- : chart.total > budget
172
- ? `${observed}${unitSuffix} / ${formatValue(budget)} budget · OVER BUDGET by ${chart.truncated ? "at least " : ""}${formatValue(chart.total - budget)}`
173
- : chart.truncated
174
- ? `${observed}${unitSuffix} / ${formatValue(budget)} budget · state unknown · query limit reached`
175
- : `${observed}${unitSuffix} / ${formatValue(budget)} budget · ${formatValue(budget - chart.total)} remaining`;
176
- const lines = [
177
- truncateToWidth(theme.bold(options.title), safeWidth, ""),
178
- truncateToWidth(budgetState, safeWidth, "…"),
179
- ...(options.subtitle ? [truncateToWidth(options.subtitle, safeWidth, "…")] : []),
180
- "",
181
- ];
182
- if (maximum === 0) {
183
- lines.push(theme.fg("dim", options.noDataText));
184
- return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
185
- }
186
-
187
- for (let row = 0; row < USAGE_CHART_HEIGHT; row += 1) {
188
- const fromBottom = USAGE_CHART_HEIGHT - row - 1;
189
- const lower = maximum * fromBottom / USAGE_CHART_HEIGHT;
190
- const upper = maximum * (fromBottom + 1) / USAGE_CHART_HEIGHT;
191
- const thresholdRow = budget !== undefined && budget > lower && budget <= upper;
192
- const label = thresholdRow ? formatValue(budget) : row === 0 ? formatValue(maximum) : row === Math.floor(USAGE_CHART_HEIGHT / 2) ? formatValue(maximum / 2) : "";
193
- if (thresholdRow) {
194
- const color = chart.total > budget ? "error" : "warning";
195
- lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${theme.fg(color, "┄".repeat(plotWidth))}`);
196
- continue;
197
- }
198
- let plot = "";
199
- for (const bucket of buckets) {
200
- const scaled = bucket.total / maximum * USAGE_CHART_HEIGHT;
201
- const occupancy = Math.max(0, Math.min(1, scaled - fromBottom));
202
- if (occupancy <= 0) {
203
- plot += " ".repeat(barStep);
204
- continue;
205
- }
206
- const block = PARTIAL_BLOCKS[Math.max(0, Math.ceil(occupancy * PARTIAL_BLOCKS.length) - 1)]!;
207
- const valueHeight = Math.min(bucket.total, maximum * (fromBottom + Math.min(occupancy, 0.5)) / USAGE_CHART_HEIGHT);
208
- plot += seriesStyle(seriesAt(bucket, chart, valueHeight), theme)(block) + (barStep === 2 ? " " : "");
209
- }
210
- lines.push(`${label.padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", "│")}${plot}`);
211
- }
212
- lines.push(`${"0".padStart(USAGE_Y_AXIS_WIDTH - 2)} ${theme.fg("borderMuted", `└${"─".repeat(plotWidth)}`)}`);
213
- lines.push(`${" ".repeat(USAGE_Y_AXIS_WIDTH)}${axisLabels(chart.start, chart.end, chart.period, plotWidth)}`);
214
- lines.push("");
215
- const displayedSeries = chart.series.slice(0, USAGE_RENDER_MAX_SERIES);
216
- for (let index = 0; index < displayedSeries.length; index += 1) {
217
- const series = displayedSeries[index]!;
218
- const bullet = seriesStyle(index, theme)("■");
219
- lines.push(truncateToWidth(`${bullet} ${displayIdentity(series.provider)}/${displayIdentity(series.model)} ${formatValue(series.total)}`, safeWidth, "…"));
220
- }
221
- if (chart.series.length > displayedSeries.length) lines.push(truncateToWidth(theme.fg("muted", `… ${chart.series.length - displayedSeries.length} more series omitted`), safeWidth, "…"));
222
- return lines.map((line) => visibleWidth(line) <= safeWidth ? line : truncateToWidth(line, safeWidth, "…"));
223
- }
224
-
225
- export function renderUsageGraph(chart: UsageGraph, width: number, theme: UsageTheme, tokenBudget?: number): string[] {
226
- return renderChart(
227
- { period: chart.period, start: chart.start, end: chart.end, buckets: chart.buckets, series: chart.series, total: chart.totalTokens, truncated: chart.truncated },
228
- width, theme,
229
- {
230
- title: `${usagePeriod(chart.period).label} token usage`,
231
- formatValue: compact,
232
- unitSuffix: " tokens",
233
- subtitle: `input ${compact(chart.breakdown.input)} · output ${compact(chart.breakdown.output)} · cache ${compact(chart.breakdown.cacheRead + chart.breakdown.cacheWrite)}`,
234
- budget: tokenBudget,
235
- noDataText: "No recorded Pi token usage in this period.",
236
- },
237
- );
238
- }
239
-
240
- export function renderCostGraph(chart: CostGraph, width: number, theme: UsageTheme, costBudget?: number): string[] {
241
- return renderChart(
242
- { period: chart.period, start: chart.start, end: chart.end, buckets: chart.buckets, series: chart.series, total: chart.totalUsd, truncated: chart.truncated },
243
- width, theme,
244
- {
245
- title: `${usagePeriod(chart.period).label} cost`,
246
- formatValue: formatUsd,
247
- unitSuffix: "",
248
- budget: costBudget,
249
- noDataText: "No recorded Pi cost in this period.",
250
- },
251
- );
252
- }
253
-
254
- export type UsageViewKind = "tokens" | "cost";
255
- const USAGE_VIEWS: UsageViewKind[] = ["tokens", "cost"];
256
-
257
- /**
258
- * One bounded round trip: the daemon discovers distinct scopes (still capped at
259
- * USAGE_MAX_DISTINCT_SCOPES -- more scopes than that is still a real, honestly-reported
260
- * truncation) and SQL-side aggregates every matching observation into (scope, metric, bucket)
261
- * sums for the exact window this panel renders. Replaces a per-scope fetch of up to
262
- * USAGE_PER_SCOPE_QUERY_LIMIT raw rows each, which fixed a *different* problem (one heavy scope
263
- * starving *other* scopes out of a shared row budget) but could still silently truncate a single
264
- * heavy scope's *own* older history within the same window -- a real incident: a scope logging
265
- * tens of thousands of rows a week had its "weekly" chart built from a few minutes of its most
266
- * recent rows alone. Aggregation has no such failure mode: result size scales with (scopes x
267
- * metrics x buckets), never with raw event count.
268
- */
269
- async function loadPiMetrics(client: JittorPanelClient, window: ReturnType<typeof resolveUsageWindow>): Promise<{ rows: UsageAggregateRow[]; truncated: boolean }> {
270
- const result = await client.call("metrics.usage_series", {
271
- source: "pi", since: window.start, until: window.end, bucketSizeMs: window.bucketSizeMs, bucketCount: window.bucketCount, scopeLimit: USAGE_MAX_DISTINCT_SCOPES,
272
- }) as { rows: UsageAggregateRow[]; truncated: boolean };
273
- return result;
274
- }
275
-
276
- export async function showUsagePanel(
277
- ctx: ExtensionCommandContext,
278
- client: JittorPanelClient,
279
- budgets: Pick<UsageBudgetControl, "getUsageTokenBudget">,
280
- now = Date.now(),
281
- initialView: UsageViewKind = "tokens",
282
- ): Promise<void> {
283
- let periodIndex = 0;
284
- let viewIndex = Math.max(0, USAGE_VIEWS.indexOf(initialView));
285
- for (;;) {
286
- const period = USAGE_PERIODS[periodIndex]!.id;
287
- const window = resolveUsageWindow(period, now);
288
- // One bounded query serves both views: token and cost metrics share the same "pi" source rows.
289
- const { rows, truncated } = await loadPiMetrics(client, window);
290
- const view = USAGE_VIEWS[viewIndex]!;
291
- const tokenChart = buildUsageGraph(rows, window, { period, truncated });
292
- const costChart = buildCostGraph(rows, window, { period, truncated });
293
- const tokenBudget = budgets.getUsageTokenBudget(period);
294
- const renderActive = (width: number, theme: UsageTheme): string[] =>
295
- view === "tokens" ? renderUsageGraph(tokenChart, width, theme, tokenBudget) : renderCostGraph(costChart, width, theme);
296
- if (ctx.mode !== "tui") {
297
- ctx.ui.notify(renderActive(80, plainTheme()).join("\n"), "info");
298
- return;
299
- }
300
- const action = await ctx.ui.custom<UsageAction>((_tui, theme, _keybindings, done) => ({
301
- invalidate() {},
302
- render(width: number): string[] {
303
- const lines = renderActive(width, theme);
304
- const controls = theme.fg("dim", "←/→/Tab period · v view · r refresh · Esc close");
305
- return [...lines, "", truncateToWidth(controls, width, "…")];
306
- },
307
- handleInput(data: string): void {
308
- if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") done("close");
309
- else if (matchesKey(data, "left") || matchesKey(data, "shift+tab")) done("period-prev");
310
- else if (matchesKey(data, "right") || matchesKey(data, "tab")) done("period-next");
311
- else if (data === "v") done("view-next");
312
- else if (data === "r") done("refresh");
313
- },
314
- }));
315
- if (!action || action === "close") return;
316
- if (action === "period-prev") periodIndex = (periodIndex - 1 + USAGE_PERIODS.length) % USAGE_PERIODS.length;
317
- if (action === "period-next") periodIndex = (periodIndex + 1) % USAGE_PERIODS.length;
318
- if (action === "view-next") viewIndex = (viewIndex + 1) % USAGE_VIEWS.length;
319
- }
320
- }