@aaroncarry/pi-usage 0.1.0 → 0.2.1
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 +87 -6
- package/README.zh-CN.md +85 -5
- package/package.json +10 -3
- package/src/config.ts +3 -0
- package/src/index.ts +144 -7
- package/src/trends/aggregate.ts +822 -0
- package/src/trends/dashboard.ts +364 -0
- package/src/trends/insights.ts +152 -0
- package/src/trends/render.ts +393 -0
- package/src/ui/card.ts +39 -1
- package/src/ui/statusline.ts +5 -2
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trends rendering: braille line charts, block bars, calendar heatmap, model
|
|
3
|
+
* distribution bars, and the provider→model table. All pure string/Component
|
|
4
|
+
* builders — no data access.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
8
|
+
import { formatTokens } from "../session-usage.ts";
|
|
9
|
+
import type { ThemeLike } from "../ui/statusline.ts";
|
|
10
|
+
import type { DistributionRow } from "./aggregate.ts";
|
|
11
|
+
|
|
12
|
+
// ── Formatting ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Cost formatting tiers: 0 → "-", tiny → 4dp, then 2dp/1dp/integer. */
|
|
15
|
+
export function formatCost(value: number): string {
|
|
16
|
+
if (value === 0) return "-";
|
|
17
|
+
if (value < 0.01) return `$${value.toFixed(4)}`;
|
|
18
|
+
if (value < 100) return value < 10 ? `$${value.toFixed(2)}` : `$${value.toFixed(1)}`;
|
|
19
|
+
return `$${Math.round(value)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Compact count formatting: 4,643 style grouping. */
|
|
23
|
+
export function formatCount(value: number): string {
|
|
24
|
+
if (value === 0) return "-";
|
|
25
|
+
return value.toLocaleString("en-US");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Short local date like "9/13". */
|
|
29
|
+
export function formatShortDate(time: number): string {
|
|
30
|
+
const date = new Date(time);
|
|
31
|
+
return `${date.getMonth() + 1}/${date.getDate()}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Local day label like "09-06". */
|
|
35
|
+
export function formatDayLabel(time: number): string {
|
|
36
|
+
const date = new Date(time);
|
|
37
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
38
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
39
|
+
return `${month}-${day}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Block bars (8 levels per cell) ───────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
45
|
+
const SPARK_CELLS = 8;
|
|
46
|
+
|
|
47
|
+
function blockFor(value: number, max: number): string {
|
|
48
|
+
if (max <= 0 || value <= 0) return BLOCKS[0]!;
|
|
49
|
+
const level = Math.min(SPARK_CELLS - 1, Math.max(1, Math.round((value / max) * (SPARK_CELLS - 1))));
|
|
50
|
+
return BLOCKS[level]!;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Row of block bars, one cell per day (missing days render empty). */
|
|
54
|
+
export function blockBars(days: { dayStart: number; value: number }[], cells: number, now = Date.now()): { bars: string; max: number } {
|
|
55
|
+
const dayMs = 86_400_000;
|
|
56
|
+
const todayStart = new Date(now).setHours(0, 0, 0, 0);
|
|
57
|
+
const byDay = new Map(days.map((entry) => [entry.dayStart, entry.value]));
|
|
58
|
+
const values: number[] = [];
|
|
59
|
+
for (let index = cells - 1; index >= 0; index--) {
|
|
60
|
+
values.push(byDay.get(todayStart - index * dayMs) ?? 0);
|
|
61
|
+
}
|
|
62
|
+
const max = Math.max(...values, 0);
|
|
63
|
+
return { bars: values.map((value) => blockFor(value, max)).join(""), max };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Compact sparkline string like "▁▂▁▄█ 1.2M" (value suffix included). */
|
|
67
|
+
export function sparkline(days: { dayStart: number; value: number }[], total: number, now = Date.now()): string {
|
|
68
|
+
return `${blockBars(days, 7, now).bars} ${formatTokens(total)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
/** Compact sparkline string like "▁▂▁▄█ 1.2M" from raw per-day values. */
|
|
73
|
+
export function sparklineString(days: number[]): string {
|
|
74
|
+
const max = Math.max(...days, 0);
|
|
75
|
+
return `${days.map((value) => blockFor(value, max)).join("")} ${formatTokens(days.reduce((total, value) => total + value, 0))}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Model distribution bars ──────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export function renderModelBars(
|
|
81
|
+
rows: { label: string; value: number }[],
|
|
82
|
+
theme: ThemeLike,
|
|
83
|
+
width: number,
|
|
84
|
+
formatValue: (value: number) => string = formatTokens,
|
|
85
|
+
): string[] {
|
|
86
|
+
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
const share = total > 0 ? row.value / total : 0;
|
|
90
|
+
const label = row.label.length > 24 ? `${row.label.slice(0, 23)}…` : row.label;
|
|
91
|
+
const countWidth = Math.max(4, formatValue(row.value).length + String(Math.round(share * 100)).length + 4);
|
|
92
|
+
const barWidth = Math.max(4, Math.min(30, width - label.length - countWidth - 4));
|
|
93
|
+
const filled = Math.round(share * barWidth);
|
|
94
|
+
const bar = `${"█".repeat(filled)}${"░".repeat(Math.max(0, barWidth - filled))}`;
|
|
95
|
+
lines.push(
|
|
96
|
+
` ${theme.fg("muted", label.padEnd(24).slice(0, 24))} ${theme.fg("accent", bar)} ${String(Math.round(share * 100)).padStart(3)}% ${theme.fg("dim", formatValue(row.value))}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Calendar heatmap ─────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const HEAT_LEVELS = ["░", "▒", "▓", "█"];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Weekly calendar heatmap (rows = weekday, columns = weeks), Monday start.
|
|
108
|
+
* `weeks` columns; intensity levels come from quartiles of the nonzero days.
|
|
109
|
+
*/
|
|
110
|
+
export function renderHeatmap(
|
|
111
|
+
days: { dayStart: number; value: number }[],
|
|
112
|
+
theme: ThemeLike,
|
|
113
|
+
weeks: number,
|
|
114
|
+
now = Date.now(),
|
|
115
|
+
): string[] {
|
|
116
|
+
const dayMs = 86_400_000;
|
|
117
|
+
const byDay = new Map(days.map((entry) => [entry.dayStart, entry.value]));
|
|
118
|
+
const todayStart = new Date(now).setHours(0, 0, 0, 0);
|
|
119
|
+
const todayDow = (new Date(todayStart).getDay() + 6) % 7; // Monday = 0
|
|
120
|
+
const lastMonday = todayStart - todayDow * dayMs;
|
|
121
|
+
|
|
122
|
+
const nonzero = days.filter((entry) => entry.value > 0).map((entry) => entry.value).sort((a, b) => a - b);
|
|
123
|
+
const quartile = (fraction: number): number => {
|
|
124
|
+
if (nonzero.length === 0) return 0;
|
|
125
|
+
const index = Math.min(nonzero.length - 1, Math.floor(fraction * nonzero.length));
|
|
126
|
+
return nonzero[index]!;
|
|
127
|
+
};
|
|
128
|
+
const light = quartile(0.25);
|
|
129
|
+
const mid = quartile(0.5);
|
|
130
|
+
const heavy = quartile(0.75);
|
|
131
|
+
|
|
132
|
+
const lines: string[] = [];
|
|
133
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
134
|
+
let line = "";
|
|
135
|
+
for (let week = weeks - 1; week >= 0; week--) {
|
|
136
|
+
const dayMsStart = lastMonday - week * 7 * dayMs + dow * dayMs;
|
|
137
|
+
if (dayMsStart > todayStart) {
|
|
138
|
+
line += " ";
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const value = byDay.get(dayMsStart) ?? 0;
|
|
142
|
+
let level = -1;
|
|
143
|
+
if (value > 0) {
|
|
144
|
+
level = 0;
|
|
145
|
+
if (value >= light) level = 1;
|
|
146
|
+
if (value >= mid) level = 2;
|
|
147
|
+
if (value >= heavy) level = 3;
|
|
148
|
+
}
|
|
149
|
+
const glyph = level < 0 ? theme.fg("muted", "░") : theme.fg(level >= 3 ? "accent" : level === 2 ? "success" : "dim", HEAT_LEVELS[level]!);
|
|
150
|
+
line += `${glyph} `;
|
|
151
|
+
}
|
|
152
|
+
lines.push(` ${line}`);
|
|
153
|
+
}
|
|
154
|
+
return lines;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Braille line chart ───────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
const BRAILLE_BASE = 0x2800;
|
|
160
|
+
// Dot bit layout: [x % 2][y % 4]; y = 0 is the top row of the cell.
|
|
161
|
+
const DOT_BITS = [
|
|
162
|
+
[0x01, 0x02, 0x04, 0x40],
|
|
163
|
+
[0x08, 0x10, 0x20, 0x80],
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
export interface BrailleSeries {
|
|
167
|
+
label: string;
|
|
168
|
+
values: number[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Multi-series braille line chart. Series are OR-merged per cell; the last
|
|
173
|
+
* drawn series owns the color (caller orders series least-important first).
|
|
174
|
+
*/
|
|
175
|
+
export function renderBrailleChart(
|
|
176
|
+
series: BrailleSeries[],
|
|
177
|
+
theme: ThemeLike,
|
|
178
|
+
width: number,
|
|
179
|
+
height: number,
|
|
180
|
+
startMs: number,
|
|
181
|
+
endMs: number,
|
|
182
|
+
formatValue: (value: number) => string = formatTokens,
|
|
183
|
+
): string[] {
|
|
184
|
+
const plotHeight = Math.max(4, height);
|
|
185
|
+
const labelWidth = Math.max(6, ...series.map((s) => formatValue(Math.max(...s.values, 0)).length));
|
|
186
|
+
const plotWidth = Math.max(10, width - labelWidth - 3);
|
|
187
|
+
const dotWidth = plotWidth * 2;
|
|
188
|
+
const dotHeight = plotHeight * 4;
|
|
189
|
+
|
|
190
|
+
const masks: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(0));
|
|
191
|
+
const owners: number[][] = Array.from({ length: plotHeight }, () => new Array<number>(plotWidth).fill(-2));
|
|
192
|
+
|
|
193
|
+
const drawn = series.filter((entry) => entry.values.some((value) => value > 0));
|
|
194
|
+
const yMax = Math.max(1, ...drawn.map((s) => Math.max(...s.values, 0)));
|
|
195
|
+
drawn.forEach((seriesEntry, seriesIndex) => {
|
|
196
|
+
const count = seriesEntry.values.length;
|
|
197
|
+
if (count === 0) return;
|
|
198
|
+
const setDot = (x: number, y: number): void => {
|
|
199
|
+
if (x < 0 || x >= dotWidth || y < 0 || y >= dotHeight) return;
|
|
200
|
+
const col = Math.floor(x / 2);
|
|
201
|
+
const row = Math.floor(y / 4);
|
|
202
|
+
masks[row]![col]! |= DOT_BITS[x % 2]![y % 4]!;
|
|
203
|
+
owners[row]![col] = seriesIndex;
|
|
204
|
+
};
|
|
205
|
+
// Active range: only draw between the first and last nonzero bucket so
|
|
206
|
+
// stopped series do not leave a long horizontal zero tail.
|
|
207
|
+
let firstIndex = 0;
|
|
208
|
+
let lastIndex = count - 1;
|
|
209
|
+
while (firstIndex < count && seriesEntry.values[firstIndex]! <= 0) firstIndex += 1;
|
|
210
|
+
while (lastIndex >= 0 && seriesEntry.values[lastIndex]! <= 0) lastIndex -= 1;
|
|
211
|
+
const previous = seriesEntry.values.map((value, index) => ({
|
|
212
|
+
x: count === 1 ? dotWidth - 1 : Math.round((index / (count - 1)) * (dotWidth - 1)),
|
|
213
|
+
y: Math.round((1 - value / yMax) * (dotHeight - 1)),
|
|
214
|
+
}));
|
|
215
|
+
for (let index = firstIndex; index <= lastIndex; index++) {
|
|
216
|
+
setDot(previous[index]!.x, previous[index]!.y);
|
|
217
|
+
if (index > firstIndex) {
|
|
218
|
+
const from = previous[index - 1]!;
|
|
219
|
+
const to = previous[index]!;
|
|
220
|
+
const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y), 1);
|
|
221
|
+
for (let step = 1; step < steps; step++) {
|
|
222
|
+
setDot(
|
|
223
|
+
Math.round(from.x + ((to.x - from.x) * step) / steps),
|
|
224
|
+
Math.round(from.y + ((to.y - from.y) * step) / steps),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const SERIES_COLORS = ["accent", "success", "warning", "muted", "error", "dim"] as const;
|
|
232
|
+
const lines: string[] = [];
|
|
233
|
+
const axisLabel = (row: number): string => {
|
|
234
|
+
const value = (1 - row / (plotHeight - 1)) * yMax;
|
|
235
|
+
return formatValue(Math.max(0, value));
|
|
236
|
+
};
|
|
237
|
+
for (let row = 0; row < plotHeight; row++) {
|
|
238
|
+
let line = row === 0 || row === plotHeight - 1 || row === Math.floor(plotHeight / 2)
|
|
239
|
+
? axisLabel(row).padStart(labelWidth) + (row === plotHeight - 1 ? " └" : " ┤")
|
|
240
|
+
: " ".repeat(labelWidth) + " │";
|
|
241
|
+
for (let col = 0; col < plotWidth; col++) {
|
|
242
|
+
const mask = masks[row]![col]!;
|
|
243
|
+
if (mask === 0) {
|
|
244
|
+
line += " ";
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const owner = owners[row]![col]!;
|
|
248
|
+
const color = SERIES_COLORS[owner % SERIES_COLORS.length]!;
|
|
249
|
+
line += theme.fg(color, String.fromCharCode(BRAILLE_BASE + mask));
|
|
250
|
+
}
|
|
251
|
+
lines.push(line);
|
|
252
|
+
}
|
|
253
|
+
// X axis labels: start + end (middle label when there is room).
|
|
254
|
+
const startLabel = formatDayLabel(startMs);
|
|
255
|
+
const endLabel = formatDayLabel(endMs);
|
|
256
|
+
const midTime = (startMs + endMs) / 2;
|
|
257
|
+
const midLabel = formatDayLabel(midTime);
|
|
258
|
+
const hasMid = plotWidth >= startLabel.length + endLabel.length + midLabel.length + 10;
|
|
259
|
+
let axis = " ".repeat(labelWidth + 2);
|
|
260
|
+
const midCol = hasMid ? Math.floor(plotWidth / 2 - midLabel.length / 2) : -1;
|
|
261
|
+
for (let col = 0; col < plotWidth; col++) {
|
|
262
|
+
if (col < startLabel.length) axis += col < startLabel.length ? startLabel[col]! : " ";
|
|
263
|
+
else if (midCol >= 0 && col >= midCol && col < midCol + midLabel.length) axis += midLabel[col - midCol]!;
|
|
264
|
+
else if (col >= plotWidth - endLabel.length) axis += endLabel[col - (plotWidth - endLabel.length)]!;
|
|
265
|
+
else axis += " ";
|
|
266
|
+
}
|
|
267
|
+
lines.push(theme.fg("dim", axis));
|
|
268
|
+
return lines;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Legend line for the chart: colored markers + labels. */
|
|
272
|
+
export function renderChartLegend(series: { label: string }[], theme: ThemeLike): string {
|
|
273
|
+
const colors = ["accent", "success", "warning", "muted", "error", "dim"] as const;
|
|
274
|
+
return series
|
|
275
|
+
.map((entry, index) => theme.fg(colors[index % colors.length]!, `● ${entry.label}`))
|
|
276
|
+
.join(theme.fg("dim", " "));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Provider→model table ─────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
interface TableColumn {
|
|
282
|
+
header: string;
|
|
283
|
+
width: number;
|
|
284
|
+
value: (row: DistributionRow) => string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const TABLE_COLUMNS: TableColumn[] = [
|
|
288
|
+
{ header: "Sessions", width: 9, value: (row) => formatCount(row.sessions) },
|
|
289
|
+
{ header: "Msgs", width: 9, value: (row) => formatCount(row.messages) },
|
|
290
|
+
{ header: "Cost", width: 9, value: (row) => formatCost(row.cost) },
|
|
291
|
+
{ header: "Tokens", width: 9, value: (row) => formatTokens(row.tokens) },
|
|
292
|
+
{ header: "↑In", width: 8, value: (row) => formatTokens(row.input + row.cacheWrite) },
|
|
293
|
+
{ header: "↓Out", width: 8, value: (row) => formatTokens(row.output) },
|
|
294
|
+
{ header: "Cache", width: 8, value: (row) => formatTokens(row.cacheRead + row.cacheWrite) },
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
function fitColumns(width: number, nameWidth: number): TableColumn[] {
|
|
298
|
+
const columnsWidth = (columns: TableColumn[]): number => columns.reduce((total, column) => total + column.width + 2, 0);
|
|
299
|
+
for (let count = TABLE_COLUMNS.length; count >= 1; count--) {
|
|
300
|
+
const columns = TABLE_COLUMNS.slice(0, count);
|
|
301
|
+
if (nameWidth + columnsWidth(columns) <= width) return columns;
|
|
302
|
+
}
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Provider→model table rows. `expanded` holds provider keys rendered open.
|
|
308
|
+
* Group rows arrive pre-grouped: provider rows with `children`.
|
|
309
|
+
*/
|
|
310
|
+
export interface TableRowGroup {
|
|
311
|
+
provider: string;
|
|
312
|
+
row: DistributionRow;
|
|
313
|
+
children: DistributionRow[];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function renderTable(
|
|
317
|
+
groups: TableRowGroup[],
|
|
318
|
+
theme: ThemeLike,
|
|
319
|
+
width: number,
|
|
320
|
+
expanded: Set<string>,
|
|
321
|
+
selected: number,
|
|
322
|
+
totalSessions?: number,
|
|
323
|
+
): string[] {
|
|
324
|
+
const nameWidth = Math.min(26, Math.max(16, Math.floor(width * 0.35)));
|
|
325
|
+
const columns = fitColumns(width, nameWidth);
|
|
326
|
+
if (columns.length === 0) return [theme.fg("error", " terminal too narrow for the table")];
|
|
327
|
+
|
|
328
|
+
const header =
|
|
329
|
+
theme.fg("dim", "Provider / Model".padEnd(nameWidth)) +
|
|
330
|
+
columns.map((column) => theme.fg("dim", column.header.padStart(column.width + 1))).join("");
|
|
331
|
+
const lines = [header, theme.fg("dim", "─".repeat(Math.min(width, nameWidth + columns.reduce((total, column) => total + column.width + 2, 0))))];
|
|
332
|
+
|
|
333
|
+
const renderRow = (row: DistributionRow, indent: number, dim: boolean, isSelected: boolean, marker?: string): string => {
|
|
334
|
+
const name = (marker ? `${marker} ` : "") + row.model;
|
|
335
|
+
const truncated = name.length > nameWidth - indent ? `${name.slice(0, nameWidth - indent - 1)}…` : name;
|
|
336
|
+
const segments = [
|
|
337
|
+
truncated.padEnd(nameWidth - indent),
|
|
338
|
+
...columns.map((column) => column.value(row).padStart(column.width + 1)),
|
|
339
|
+
];
|
|
340
|
+
const line = " ".repeat(indent) + segments.join("");
|
|
341
|
+
return isSelected ? theme.fg("accent", line) : theme.fg(dim ? "muted" : "text", line);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
groups.forEach((group, groupIndex) => {
|
|
345
|
+
const isOpen = expanded.has(group.provider);
|
|
346
|
+
const marker = group.children.length > 0 ? (isOpen ? "▾" : "▸") : " ";
|
|
347
|
+
lines.push(renderRow({ ...group.row, model: group.provider }, 1, false, selected === groupIndex, marker));
|
|
348
|
+
if (isOpen) {
|
|
349
|
+
for (const child of group.children) {
|
|
350
|
+
lines.push(renderRow(child, 4, true, false));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const total = groups.reduce(
|
|
356
|
+
(accumulator, group) => {
|
|
357
|
+
accumulator.messages += group.row.messages;
|
|
358
|
+
accumulator.cost += group.row.cost;
|
|
359
|
+
accumulator.tokens += group.row.tokens;
|
|
360
|
+
accumulator.input += group.row.input;
|
|
361
|
+
accumulator.output += group.row.output;
|
|
362
|
+
accumulator.cacheRead += group.row.cacheRead;
|
|
363
|
+
accumulator.cacheWrite += group.row.cacheWrite;
|
|
364
|
+
accumulator.reasoning += group.row.reasoning;
|
|
365
|
+
return accumulator;
|
|
366
|
+
},
|
|
367
|
+
{ messages: 0, cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, provider: "", model: "", sessions: 0 },
|
|
368
|
+
);
|
|
369
|
+
lines.push(theme.fg("dim", "─".repeat(Math.min(width, nameWidth + columns.reduce((total2, column) => total2 + column.width + 2, 0)))));
|
|
370
|
+
const totalSessionsValue = totalSessions !== undefined ? formatCount(totalSessions) : "-";
|
|
371
|
+
const totalRow: DistributionRow = { ...total, sessions: 0, provider: "", model: "" };
|
|
372
|
+
lines.push(
|
|
373
|
+
theme.bold("Total".padEnd(nameWidth)) +
|
|
374
|
+
columns
|
|
375
|
+
.map((column) =>
|
|
376
|
+
theme.bold(
|
|
377
|
+
(column.header === "Sessions" ? totalSessionsValue : column.value(totalRow)).padStart(column.width + 1),
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
.join(""),
|
|
381
|
+
);
|
|
382
|
+
return lines;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Card/table footnote explaining the token accounting. */
|
|
386
|
+
export function tableFootnote(theme: ThemeLike): string {
|
|
387
|
+
return theme.fg("dim", "Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite · Cache = Read + Write");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Wrap table lines into a pi-tui component. */
|
|
391
|
+
export function tableComponent(lines: string[]): Component {
|
|
392
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
393
|
+
}
|
package/src/ui/card.ts
CHANGED
|
@@ -8,14 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
import { Box, Text, type Component } from "@earendil-works/pi-tui";
|
|
10
10
|
import { formatBar, formatMoney, formatResetSuffix } from "../format.ts";
|
|
11
|
+
import { formatTokens } from "../session-usage.ts";
|
|
12
|
+
import { blockBars, renderModelBars } from "../trends/render.ts";
|
|
11
13
|
import type { AccountBalance } from "../types.ts";
|
|
12
14
|
import type { ThemeLike } from "./statusline.ts";
|
|
13
15
|
|
|
16
|
+
/** 30-day usage summary embedded in the card (scheme 1). */
|
|
17
|
+
export interface CardTrendsSummary {
|
|
18
|
+
/** Fresh tokens per day, oldest first; index 29 = `endsAt` day. Zeros for missing days. */
|
|
19
|
+
days: number[];
|
|
20
|
+
/** Day start (epoch ms) of the newest cell in `days`. */
|
|
21
|
+
endsAt: number;
|
|
22
|
+
total: number;
|
|
23
|
+
cost: number;
|
|
24
|
+
models: { label: string; tokens: number }[];
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export interface UsageCardData {
|
|
15
28
|
balances: AccountBalance[];
|
|
16
29
|
activeProviderId?: string;
|
|
17
30
|
/** Epoch ms when the snapshot was taken. */
|
|
18
31
|
generatedAt: number;
|
|
32
|
+
trends?: CardTrendsSummary;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
function displayTitle(balance: AccountBalance): string {
|
|
@@ -43,9 +57,33 @@ export function buildUsageCard(data: UsageCardData, theme: ThemeLike): Component
|
|
|
43
57
|
box.addChild(line);
|
|
44
58
|
}
|
|
45
59
|
}
|
|
60
|
+
if (data.trends && data.trends.days.some((value) => value > 0)) {
|
|
61
|
+
for (const child of renderTrendsSummary(data.trends, theme)) {
|
|
62
|
+
box.addChild(child);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
46
65
|
return box;
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
function renderTrendsSummary(trends: CardTrendsSummary, theme: ThemeLike): Text[] {
|
|
69
|
+
const dayMs = 86_400_000;
|
|
70
|
+
const days = trends.days.map((value, index) => ({ dayStart: trends.endsAt - (29 - index) * dayMs, value }));
|
|
71
|
+
const { bars } = blockBars(days, 30, trends.endsAt + dayMs - 1);
|
|
72
|
+
const children: Text[] = [
|
|
73
|
+
new Text(theme.fg("dim", "── Last 30 days ────────────────────────────"), 0, 1),
|
|
74
|
+
new Text(
|
|
75
|
+
` ${theme.fg("dim", "tokens")} ${theme.fg("accent", bars)} ${theme.fg("dim", `${formatTokens(trends.total)} · ${formatMoney({ amount: trends.cost, currency: "USD" })}`)}`,
|
|
76
|
+
0,
|
|
77
|
+
0,
|
|
78
|
+
),
|
|
79
|
+
];
|
|
80
|
+
for (const line of renderModelBars(trends.models.map((model) => ({ label: model.label, value: model.tokens })), theme, 50)) {
|
|
81
|
+
children.push(new Text(line, 0, 0));
|
|
82
|
+
}
|
|
83
|
+
children.push(new Text(theme.fg("dim", "/trends — full usage dashboard"), 0, 0));
|
|
84
|
+
return children;
|
|
85
|
+
}
|
|
86
|
+
|
|
49
87
|
function orderActiveFirst(balances: AccountBalance[], activeProviderId?: string): AccountBalance[] {
|
|
50
88
|
if (!activeProviderId) return [...balances];
|
|
51
89
|
const active = balances.filter((balance) => balance.providerId === activeProviderId);
|
|
@@ -62,7 +100,7 @@ function renderAccount(balance: AccountBalance, theme: ThemeLike): Text[] {
|
|
|
62
100
|
const color = window.usedPercent >= 90 ? "error" : window.usedPercent >= 70 ? "warning" : "accent";
|
|
63
101
|
const barLine =
|
|
64
102
|
` ${theme.fg("dim", window.label.padEnd(8))}${theme.fg(color, formatBar(window.usedPercent))}` +
|
|
65
|
-
` ${String(Math.round(window.usedPercent)).padStart(3)}
|
|
103
|
+
` ${String(Math.round(window.usedPercent)).padStart(3)}% used${theme.fg("dim", formatResetSuffix(window.resetsAt))}`;
|
|
66
104
|
children.push(new Text(barLine, 0, 0));
|
|
67
105
|
}
|
|
68
106
|
if (balance.balance) {
|
package/src/ui/statusline.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { AccountBalance } from "../types.ts";
|
|
|
13
13
|
|
|
14
14
|
/** Structural subset of pi's Theme used by the status line/panel. */
|
|
15
15
|
export interface ThemeLike {
|
|
16
|
-
fg(color: "dim" | "muted" | "accent" | "success" | "warning" | "error", text: string): string;
|
|
16
|
+
fg(color: "dim" | "muted" | "accent" | "success" | "warning" | "error" | "text", text: string): string;
|
|
17
17
|
bold(text: string): string;
|
|
18
18
|
bg(color: "customMessageBg", text: string): string;
|
|
19
19
|
}
|
|
@@ -33,7 +33,7 @@ export function accountSummary(balance: AccountBalance, options?: { allWindows?:
|
|
|
33
33
|
const parts: string[] = [];
|
|
34
34
|
const windows = options?.allWindows ? balance.windows : balance.windows.slice(0, 1);
|
|
35
35
|
for (const window of windows) {
|
|
36
|
-
parts.push(`${window.label} ${Math.round(window.usedPercent)}
|
|
36
|
+
parts.push(`${window.label} ${Math.round(window.usedPercent)}% used`);
|
|
37
37
|
}
|
|
38
38
|
if (balance.balance) parts.push(formatMoney(balance.balance));
|
|
39
39
|
return parts.length > 0 ? `${balance.label} ${parts.join(" · ")}` : balance.label;
|
|
@@ -58,6 +58,8 @@ export function formatStatusLine(options: {
|
|
|
58
58
|
activeProviderId?: string;
|
|
59
59
|
theme: ThemeLike;
|
|
60
60
|
consumption?: SessionUsageTotals;
|
|
61
|
+
/** Pre-rendered 7-day sparkline segment (scheme 4), appended last. */
|
|
62
|
+
sparkline?: string;
|
|
61
63
|
}): string | undefined {
|
|
62
64
|
if (options.mode === "off") return undefined;
|
|
63
65
|
const ordered = orderActiveFirst(options.balances, options.activeProviderId);
|
|
@@ -72,6 +74,7 @@ export function formatStatusLine(options: {
|
|
|
72
74
|
});
|
|
73
75
|
const consumptionSegment = formatConsumptionSegment(options.consumption, options.theme);
|
|
74
76
|
if (consumptionSegment) segments.push(consumptionSegment);
|
|
77
|
+
if (options.sparkline) segments.push(options.theme.fg("dim", `7d ${options.sparkline}`));
|
|
75
78
|
if (segments.length === 0) return undefined;
|
|
76
79
|
return segments.join(options.theme.fg("dim", " · "));
|
|
77
80
|
}
|