@nredd/pi-session-kit 0.3.0 → 0.5.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 +57 -17
- package/package.json +16 -6
- package/src/clock.ts +25 -0
- package/src/goal/index.ts +5 -5
- package/src/index.ts +35 -14
- package/src/loop/index.ts +5 -5
- package/src/monitor/coalescer.ts +7 -6
- package/src/monitor/index.ts +12 -5
- package/src/monitor/manager.ts +30 -16
- package/src/monitor/pidfiles.ts +7 -2
- package/src/monitor/process.ts +3 -2
- package/src/rules/index.ts +2 -2
- package/src/stats/aggregate.ts +3 -0
- package/src/stats/chart.ts +3 -34
- package/src/stats/component.ts +290 -138
- package/src/stats/days.ts +7 -0
- package/src/stats/heatmap.ts +9 -2
- package/src/stats/index.ts +10 -3
- package/src/stats/ticks.ts +241 -0
- package/src/titles/index.ts +11 -6
package/src/stats/chart.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The "Tokens per day" line chart: an asciichart-style plot (`╭╮╰╯│─`) with nice y-axis ticks and
|
|
3
|
-
*
|
|
3
|
+
* time ticks in `ticks.ts`. Pure: colours go through a `paint` callback so tests can render plain text.
|
|
4
4
|
*
|
|
5
5
|
* Days are bucketed (1, 2, 3, 7, 14 or 30 days) so the whole range fits the width, then sampled
|
|
6
6
|
* with linear interpolation across every column so a short range draws a smooth curve rather
|
|
7
7
|
* than a staircase.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { addDays, daysBetween
|
|
10
|
+
import { addDays, daysBetween } from "./days.ts";
|
|
11
11
|
import { axisLabel } from "./format.ts";
|
|
12
12
|
|
|
13
13
|
export const BUCKET_SIZES = [1, 2, 3, 7, 14, 30] as const;
|
|
@@ -194,7 +194,7 @@ export function plot<C extends string | number>(series: readonly Series<C>[], op
|
|
|
194
194
|
const y = height - r;
|
|
195
195
|
const tick = Number.isInteger(y / perTick) ? y / perTick : -1;
|
|
196
196
|
const label = tick >= 0 ? (labels[tick] ?? "") : "";
|
|
197
|
-
const axis =
|
|
197
|
+
const axis = tick >= 0 ? "┤" : "│";
|
|
198
198
|
let body = "";
|
|
199
199
|
let run = "";
|
|
200
200
|
let runColor: C | undefined;
|
|
@@ -214,34 +214,3 @@ export function plot<C extends string | number>(series: readonly Series<C>[], op
|
|
|
214
214
|
}
|
|
215
215
|
return { lines, gutter: labelWidth + 2, max };
|
|
216
216
|
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Two to five date labels under a plot of `width` columns spanning `buckets`: first and last always,
|
|
220
|
-
* evenly spaced ones between when they fit with two spaces of air. Never past the plot's right edge.
|
|
221
|
-
*/
|
|
222
|
-
export function xAxis(buckets: readonly Bucket[], width: number, gutter: number): string {
|
|
223
|
-
if (buckets.length === 0 || width <= 0) return "";
|
|
224
|
-
const last = buckets.length - 1;
|
|
225
|
-
const at = (col: number) => buckets[Math.round((col * last) / Math.max(1, width))]?.start ?? "";
|
|
226
|
-
const line = new Array<string>(width).fill(" ");
|
|
227
|
-
const place = (col: number, text: string): boolean => {
|
|
228
|
-
const start = Math.max(0, Math.min(width - text.length, col));
|
|
229
|
-
for (let i = Math.max(0, start - 2); i < Math.min(width, start + text.length + 2); i++) {
|
|
230
|
-
if (line[i] !== " ") return false;
|
|
231
|
-
}
|
|
232
|
-
for (let i = 0; i < text.length && start + i < width; i++) line[start + i] = text[i] ?? " ";
|
|
233
|
-
return true;
|
|
234
|
-
};
|
|
235
|
-
const first = shortDate(buckets[0]?.start ?? "");
|
|
236
|
-
const end = shortDate(buckets[last]?.start ?? "");
|
|
237
|
-
// The newest date first: on a plot too narrow for both, today's end of the range wins.
|
|
238
|
-
if (last > 0) place(width - end.length, end);
|
|
239
|
-
place(0, first);
|
|
240
|
-
if (last === 0) return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
|
|
241
|
-
const slots = Math.max(1, Math.min(4, Math.floor(width / 14)));
|
|
242
|
-
for (let i = 1; i < slots; i++) {
|
|
243
|
-
const col = Math.round((i * width) / slots);
|
|
244
|
-
place(col - 3, shortDate(at(col)));
|
|
245
|
-
}
|
|
246
|
-
return `${" ".repeat(gutter)}${line.join("").trimEnd()}`;
|
|
247
|
-
}
|
package/src/stats/component.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
matches,
|
|
22
22
|
modelName,
|
|
23
23
|
modelTotals,
|
|
24
|
+
type Overview,
|
|
24
25
|
overview,
|
|
25
26
|
RANGE_LABEL,
|
|
26
27
|
RANGES,
|
|
@@ -29,12 +30,13 @@ import {
|
|
|
29
30
|
TOKENS_NOTE,
|
|
30
31
|
tokensOf,
|
|
31
32
|
} from "./aggregate.ts";
|
|
32
|
-
import { bucketize, GUTTER, plot, TICKS
|
|
33
|
-
import { dayKey, shortDate } from "./days.ts";
|
|
33
|
+
import { bucketize, GUTTER, plot, TICKS } from "./chart.ts";
|
|
34
|
+
import { dayKey, keyDate, shortDate } from "./days.ts";
|
|
34
35
|
import { duration, formatTokens, money, percent } from "./format.ts";
|
|
35
|
-
import { DAY_LABEL_WIDTH, heatmap, weeksFor, YEAR_WEEKS } from "./heatmap.ts";
|
|
36
|
+
import { DAY_LABEL_WIDTH, heatmap, MAX_WEEKS, MONTH_OVERFLOW, weeksFor, YEAR_WEEKS } from "./heatmap.ts";
|
|
36
37
|
import type { Call, Session } from "./load.ts";
|
|
37
38
|
import { seriesPalette } from "./palette.ts";
|
|
39
|
+
import { timeAxis } from "./ticks.ts";
|
|
38
40
|
|
|
39
41
|
type ThemeLike = Pick<Theme, "fg" | "bg" | "bold" | "style" | "colors">;
|
|
40
42
|
export type Tab = "Overview" | "Models";
|
|
@@ -43,16 +45,10 @@ const TABS: readonly Tab[] = ["Overview", "Models"];
|
|
|
43
45
|
const SERIES = 3;
|
|
44
46
|
/** Narrowest plot `plot` can draw; only panes too narrow for a 6-week heatmap hit it. */
|
|
45
47
|
const MIN_PLOT_WIDTH = 2;
|
|
46
|
-
/** Share of the pane the heatmap and chart span. */
|
|
47
|
-
export const CONTENT_SHARE = 0.75;
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
* Columns the heatmap and the chart both span in a `paneWidth` pane (after the 1-column margin):
|
|
51
|
-
* `CONTENT_SHARE` of it, but never narrower than a year of heatmap when that fits.
|
|
52
|
-
*/
|
|
49
|
+
/** Columns every block spans in a `paneWidth` pane: all of it after the 1-column margin. */
|
|
53
50
|
export function contentWidth(paneWidth: number): number {
|
|
54
|
-
|
|
55
|
-
return Math.min(inner, Math.max(Math.round(inner * CONTENT_SHARE), DAY_LABEL_WIDTH + YEAR_WEEKS));
|
|
51
|
+
return Math.max(1, paneWidth - 1);
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
/** Tallest chart, in rows above the zero line. */
|
|
@@ -64,8 +60,8 @@ export function chartHeight(available: number): number {
|
|
|
64
60
|
return Math.max(TICKS, Math.min(MAX_CHART_HEIGHT, steps));
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
/**
|
|
68
|
-
const
|
|
63
|
+
/** Columns between the heatmap and the numbers beside it, and between number columns. */
|
|
64
|
+
const GAP = 4;
|
|
69
65
|
|
|
70
66
|
export interface StatsViewOptions {
|
|
71
67
|
calls: readonly Call[];
|
|
@@ -84,6 +80,9 @@ export interface StatsViewOptions {
|
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
/** One scroll step of the model list: a provider header, or one or two models side by side. */
|
|
83
|
+
/** Per-provider or per-model sums the list shows. */
|
|
84
|
+
type Totals = Pick<ModelTotals, "input" | "output" | "cacheRead" | "cacheWrite" | "cost">;
|
|
85
|
+
|
|
87
86
|
interface ListRow {
|
|
88
87
|
lines: string[];
|
|
89
88
|
/** Positions of its models in list order, for the `1-4 of N` hint. */
|
|
@@ -151,7 +150,7 @@ export class StatsView implements Component {
|
|
|
151
150
|
render(width: number): string[] {
|
|
152
151
|
const w = Math.max(1, Math.floor(width));
|
|
153
152
|
const rows = Math.max(4, Math.floor(this.o.rows()));
|
|
154
|
-
const header =
|
|
153
|
+
const header = this.header(contentWidth(w), rows);
|
|
155
154
|
const room = Math.max(0, rows - header.length - 2);
|
|
156
155
|
let body: string[];
|
|
157
156
|
let hint = "";
|
|
@@ -165,6 +164,24 @@ export class StatsView implements Component {
|
|
|
165
164
|
return [...header, ...body, "", this.help(hint)].map((line) => truncateToWidth(` ${line}`, w, ""));
|
|
166
165
|
}
|
|
167
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Tabs and warning with the coverage note flush right, then the range and filter (what `r` and `m`
|
|
169
|
+
* change), then a rule. Pinned above the scrolling body. Coverage drops under the selectors when the
|
|
170
|
+
* tabs row cannot hold it; on a very short pane the rule goes first.
|
|
171
|
+
*/
|
|
172
|
+
private header(inner: number, rows: number): string[] {
|
|
173
|
+
const coverage = this.paint("muted", this.o.coverage ?? "local-only · never synced");
|
|
174
|
+
const tabs = this.tabsLine();
|
|
175
|
+
const joined = spread(tabs, coverage, inner);
|
|
176
|
+
const lines = [
|
|
177
|
+
joined ?? tabs,
|
|
178
|
+
...this.selectors(inner),
|
|
179
|
+
...(joined ? [] : [coverage]),
|
|
180
|
+
this.paint("dim", "─".repeat(inner)),
|
|
181
|
+
];
|
|
182
|
+
return lines.slice(0, Math.max(2, Math.min(lines.length, rows - 2)));
|
|
183
|
+
}
|
|
184
|
+
|
|
168
185
|
/**
|
|
169
186
|
* `↑ ↓ 3-6 of 14 models` from the list positions on screen; arrows only where more models are.
|
|
170
187
|
* The list follows the chart, so a window with no model in it is always above the list. Empty when
|
|
@@ -210,12 +227,31 @@ export class StatsView implements Component {
|
|
|
210
227
|
return `${t.bold(this.paint("accent", "/stats"))} ${tabs}${bad}`;
|
|
211
228
|
}
|
|
212
229
|
|
|
213
|
-
|
|
230
|
+
/** Range choices and the filter on one line, or the filter on its own line when they do not fit. */
|
|
231
|
+
private selectors(inner: number): string[] {
|
|
214
232
|
const t = this.o.theme;
|
|
215
233
|
const ranges = RANGES.map((r) =>
|
|
216
234
|
r === this.range ? t.bold(this.paint("accent", RANGE_LABEL[r])) : this.paint("dim", RANGE_LABEL[r]),
|
|
217
235
|
).join(this.paint("dim", " · "));
|
|
218
|
-
|
|
236
|
+
const filter = `${this.paint("muted", "filter:")} ${t.bold(sanitize(filterLabel(this.filter)))}`;
|
|
237
|
+
const line = `${ranges} ${filter}`;
|
|
238
|
+
return visibleWidth(line) <= inner ? [line] : [ranges, truncateToWidth(filter, inner, "…")];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Muted `parts` joined by a dim ` · `, wrapped onto more lines rather than cut. */
|
|
242
|
+
private wrapParts(parts: readonly string[], inner: number): string[] {
|
|
243
|
+
const sep = this.paint("dim", " · ");
|
|
244
|
+
const lines: string[] = [];
|
|
245
|
+
let line = "";
|
|
246
|
+
for (const part of parts.map((p) => this.paint("muted", p))) {
|
|
247
|
+
if (line && visibleWidth(line) + 3 + visibleWidth(part) > inner) {
|
|
248
|
+
lines.push(line);
|
|
249
|
+
line = "";
|
|
250
|
+
}
|
|
251
|
+
line += (line ? sep : "") + part;
|
|
252
|
+
}
|
|
253
|
+
if (line) lines.push(line);
|
|
254
|
+
return lines.map((l) => truncateToWidth(l, inner, "…"));
|
|
219
255
|
}
|
|
220
256
|
|
|
221
257
|
private help(hint = ""): string {
|
|
@@ -228,49 +264,96 @@ export class StatsView implements Component {
|
|
|
228
264
|
return { all, ranged: all.filter((c) => inRange(this.range, this.today, c.ms, this.now)) };
|
|
229
265
|
}
|
|
230
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Headline numbers as `label value` cells, in reading order: favorite model and total tokens,
|
|
269
|
+
* sessions and longest session, and so on.
|
|
270
|
+
*/
|
|
271
|
+
private statCells(ov: Overview): { label: string; value: string }[] {
|
|
272
|
+
const t = this.o.theme;
|
|
273
|
+
const v = (s: string) => this.paint("accent", s);
|
|
274
|
+
const days = (n: number) => `${t.bold(v(String(n)))} ${n === 1 ? "day" : "days"}`;
|
|
275
|
+
return [
|
|
276
|
+
{ label: "Favorite model", value: t.bold(v(ov.favorite ?? "-")) },
|
|
277
|
+
{ label: "Total tokens", value: `${v(formatTokens(ov.totalTokens))} ${this.paint("muted", TOKENS_NOTE)}` },
|
|
278
|
+
{ label: "Sessions", value: v(String(ov.sessions)) },
|
|
279
|
+
{ label: "Longest session", value: v(duration(ov.longestSessionMs)) },
|
|
280
|
+
{ label: "Active days", value: `${v(String(ov.activeDays))}${this.paint("muted", `/${ov.rangeDays}`)}` },
|
|
281
|
+
{ label: "Longest streak", value: days(ov.longestStreak) },
|
|
282
|
+
{ label: "Most active day", value: v(ov.mostActiveDay ? shortDate(ov.mostActiveDay) : "-") },
|
|
283
|
+
{ label: "Current streak", value: days(ov.currentStreak) },
|
|
284
|
+
{
|
|
285
|
+
label: "Cache tokens",
|
|
286
|
+
value: `${v(formatTokens(ov.cacheRead))} read · ${v(formatTokens(ov.cacheWrite))} write`,
|
|
287
|
+
},
|
|
288
|
+
{ label: "Notional cost", value: v(money(ov.cost)) },
|
|
289
|
+
];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Cells in `columns` columns, row-major, each column's labels and values aligned; `columns` shrinks
|
|
294
|
+
* until the grid fits `width`.
|
|
295
|
+
*/
|
|
296
|
+
private statGrid(cells: readonly { label: string; value: string }[], width: number, columns: number): string[] {
|
|
297
|
+
const widthOf = (cols: number) => {
|
|
298
|
+
const per = Array.from({ length: cols }, (_, c) => cells.filter((_, i) => i % cols === c));
|
|
299
|
+
// Every label is padded to the column's longest, so a cell is that plus the widest value.
|
|
300
|
+
return per.map((col) => {
|
|
301
|
+
const label = Math.max(0, ...col.map((x) => x.label.length));
|
|
302
|
+
return { label, cell: label + 2 + Math.max(0, ...col.map((x) => visibleWidth(x.value))) };
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
let cols = Math.max(1, Math.min(columns, cells.length));
|
|
306
|
+
while (cols > 1 && widthOf(cols).reduce((n, c) => n + c.cell, 0) + GAP * (cols - 1) > width) cols--;
|
|
307
|
+
const sizes = widthOf(cols);
|
|
308
|
+
const out: string[] = [];
|
|
309
|
+
for (let r = 0; r < cells.length; r += cols) {
|
|
310
|
+
out.push(
|
|
311
|
+
cells
|
|
312
|
+
.slice(r, r + cols)
|
|
313
|
+
.map((cell, c) => {
|
|
314
|
+
const size = sizes[c];
|
|
315
|
+
const text = `${this.paint("muted", cell.label.padEnd(size?.label ?? 0))} ${cell.value}`;
|
|
316
|
+
return c < cols - 1 ? padTo(text, (size?.cell ?? 0) + GAP) : text;
|
|
317
|
+
})
|
|
318
|
+
.join(""),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return out.map((line) => truncateToWidth(line, width, "…"));
|
|
322
|
+
}
|
|
323
|
+
|
|
231
324
|
private overviewBody(width: number, room: number): string[] {
|
|
232
325
|
const { all, ranged } = this.scoped();
|
|
233
|
-
const
|
|
326
|
+
const inner = contentWidth(width);
|
|
234
327
|
const out: string[] = [];
|
|
235
328
|
const daily = dailyTotals(all);
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
329
|
+
const showHeat = !isHourly(this.range);
|
|
330
|
+
if (ranged.length === 0 && !showHeat) return [this.paint("muted", "No usage in this range.")];
|
|
331
|
+
const cells =
|
|
332
|
+
ranged.length === 0
|
|
333
|
+
? []
|
|
334
|
+
: this.statCells(overview(ranged, this.o.sessions, this.range, this.today, daily.keys()));
|
|
335
|
+
const heat = (area: number) =>
|
|
336
|
+
heatmap(daily, this.today, area, (color, text) =>
|
|
337
|
+
color === "heat" ? this.paint("accent", text) : this.paint(color === "empty" ? "dim" : "muted", text),
|
|
241
338
|
);
|
|
242
|
-
|
|
339
|
+
if (showHeat) {
|
|
340
|
+
// Numbers beside the heatmap when a year of it still fits next to them, else underneath.
|
|
341
|
+
const column = this.statGrid(cells, inner, 1);
|
|
342
|
+
const beside = Math.max(0, ...column.map(visibleWidth));
|
|
343
|
+
const area = Math.min(inner - GAP - beside, DAY_LABEL_WIDTH + MAX_WEEKS + MONTH_OVERFLOW);
|
|
344
|
+
if (cells.length > 0 && weeksFor(area) >= YEAR_WEEKS) {
|
|
345
|
+
const left = heat(area);
|
|
346
|
+
for (let i = 0; i < Math.max(left.length, column.length); i++)
|
|
347
|
+
out.push(`${padTo(left[i] ?? "", area + GAP)}${column[i] ?? ""}`.trimEnd());
|
|
348
|
+
} else {
|
|
349
|
+
out.push(...heat(inner));
|
|
350
|
+
if (cells.length > 0) out.push("", ...this.statGrid(cells, inner, 2));
|
|
351
|
+
}
|
|
352
|
+
} else out.push(...this.statGrid(cells, inner, width >= 130 ? 3 : 2));
|
|
243
353
|
if (ranged.length === 0) {
|
|
244
|
-
out.push(this.paint("muted", "No usage in this range."));
|
|
354
|
+
out.push("", this.paint("muted", "No usage in this range."));
|
|
245
355
|
return out;
|
|
246
356
|
}
|
|
247
|
-
const ov = overview(ranged, this.o.sessions, this.range, this.today, daily.keys());
|
|
248
|
-
const v = (s: string) => this.paint("accent", s);
|
|
249
|
-
const pairs: [string, string][] = [
|
|
250
|
-
[
|
|
251
|
-
`Favorite model: ${t.bold(v(ov.favorite ?? "-"))}`,
|
|
252
|
-
`Total tokens: ${v(formatTokens(ov.totalTokens))} ${this.paint("muted", TOKENS_NOTE)}`,
|
|
253
|
-
],
|
|
254
|
-
[`Sessions: ${v(String(ov.sessions))}`, `Longest session: ${v(duration(ov.longestSessionMs))}`],
|
|
255
|
-
[
|
|
256
|
-
`Active days: ${v(String(ov.activeDays))}${this.paint("muted", `/${ov.rangeDays}`)}`,
|
|
257
|
-
`Longest streak: ${t.bold(v(String(ov.longestStreak)))} ${ov.longestStreak === 1 ? "day" : "days"}`,
|
|
258
|
-
],
|
|
259
|
-
[
|
|
260
|
-
`Most active day: ${v(ov.mostActiveDay ? shortDate(ov.mostActiveDay) : "-")}`,
|
|
261
|
-
`Current streak: ${t.bold(v(String(ov.currentStreak)))} ${ov.currentStreak === 1 ? "day" : "days"}`,
|
|
262
|
-
],
|
|
263
|
-
[
|
|
264
|
-
`Cache tokens: ${v(formatTokens(ov.cacheRead))} read · ${v(formatTokens(ov.cacheWrite))} write`,
|
|
265
|
-
`Notional cost: ${v(money(ov.cost))}`,
|
|
266
|
-
],
|
|
267
|
-
];
|
|
268
|
-
const col = Math.max(36, Math.min(48, Math.floor(width / 2)));
|
|
269
|
-
const twoCol = width >= col * 2;
|
|
270
|
-
for (const [left, right] of pairs) {
|
|
271
|
-
if (twoCol) out.push(`${left}${" ".repeat(Math.max(2, col - visibleWidth(left)))}${right}`);
|
|
272
|
-
else out.push(left, right);
|
|
273
|
-
}
|
|
274
357
|
// Grow the chart into whatever the pane has left, in whole tick steps.
|
|
275
358
|
const smallest = this.chart(ranged, width, TICKS).lines;
|
|
276
359
|
const height = chartHeight(room - out.length - 1 - smallest.length + TICKS);
|
|
@@ -279,8 +362,8 @@ export class StatsView implements Component {
|
|
|
279
362
|
}
|
|
280
363
|
|
|
281
364
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
365
|
+
* Title, legend, plot, time axis and a peak/average caption. One line per provider under the "all"
|
|
366
|
+
* filter, else one per model; top three. The legend shares the title row when both fit.
|
|
284
367
|
*/
|
|
285
368
|
private chart(
|
|
286
369
|
ranged: readonly Call[],
|
|
@@ -288,23 +371,49 @@ export class StatsView implements Component {
|
|
|
288
371
|
height: number,
|
|
289
372
|
): { lines: string[]; colors: Map<string, number> } {
|
|
290
373
|
const t = this.o.theme;
|
|
291
|
-
const
|
|
374
|
+
const inner = contentWidth(width);
|
|
292
375
|
const { by, keys: top } = chartSeries(this.filter, ranged, SERIES);
|
|
293
376
|
const colors = new Map(top.map((k, i): [string, number] => [k.key, i]));
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
377
|
+
const hourly = isHourly(this.range);
|
|
378
|
+
const unit = hourly ? "hour" : undefined;
|
|
379
|
+
const title = (name: string) => t.bold(name) + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`);
|
|
380
|
+
const legend = this.legend(
|
|
381
|
+
top.map((k) => k.key),
|
|
382
|
+
colors,
|
|
383
|
+
inner,
|
|
384
|
+
);
|
|
385
|
+
const head = (name: string): string[] => {
|
|
386
|
+
const row = legend.length === 1 ? spread(title(name), legend[0] ?? "", inner) : undefined;
|
|
387
|
+
if (row) return [row];
|
|
388
|
+
// A pane too narrow for the title splits the name from its unit note.
|
|
389
|
+
const lines =
|
|
390
|
+
visibleWidth(title(name)) <= inner
|
|
391
|
+
? [title(name)]
|
|
392
|
+
: [t.bold(name), this.paint("muted", truncateToWidth(`${TOKENS_NOTE} · by ${by}`, inner, "…"))];
|
|
393
|
+
return [...lines, ...legend];
|
|
394
|
+
};
|
|
395
|
+
const paint = (color: number | "axis", text: string) =>
|
|
396
|
+
color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text);
|
|
397
|
+
const plotWidth = Math.max(MIN_PLOT_WIDTH, inner - GUTTER);
|
|
398
|
+
if (hourly) {
|
|
399
|
+
const series = hourlySeries(
|
|
400
|
+
ranged,
|
|
401
|
+
this.range as "5h" | "24h",
|
|
402
|
+
this.now,
|
|
403
|
+
by === "provider" ? (c) => c.provider : modelName,
|
|
404
|
+
);
|
|
297
405
|
const p = plot(
|
|
298
406
|
top.map((k, i) => ({ values: series.map((s) => s.byKey.get(k.key) ?? 0), color: i })),
|
|
299
|
-
{
|
|
300
|
-
width: plotWidth,
|
|
301
|
-
height,
|
|
302
|
-
paint: (color, text) =>
|
|
303
|
-
color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text),
|
|
304
|
-
},
|
|
407
|
+
{ width: plotWidth, height, paint },
|
|
305
408
|
);
|
|
306
|
-
|
|
307
|
-
|
|
409
|
+
const axis = timeAxis(
|
|
410
|
+
series.map((s) => s.ms ?? 0),
|
|
411
|
+
{ kind: "hour" },
|
|
412
|
+
plotWidth,
|
|
413
|
+
p.gutter,
|
|
414
|
+
);
|
|
415
|
+
const out = [...head(`Tokens per ${unit}`), ...p.lines, ...axis.lines.map((l) => this.paint("muted", l))];
|
|
416
|
+
out.push(...this.wrapParts([`From: ${series[0]?.day}`, `To: ${series.at(-1)?.day}`], inner));
|
|
308
417
|
// Repeated wall-clock hours get an explicit offset-bearing label, even between axis edges.
|
|
309
418
|
const labels = new Map<string, string>();
|
|
310
419
|
for (const point of series) {
|
|
@@ -317,13 +426,9 @@ export class StatsView implements Component {
|
|
|
317
426
|
const peak = Math.max(0, ...series.map((s) => s.total));
|
|
318
427
|
const avg = series.reduce((n, s) => n + s.total, 0) / series.length;
|
|
319
428
|
out.push(
|
|
320
|
-
this.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
...this.legend(
|
|
324
|
-
top.map((k) => k.key),
|
|
325
|
-
colors,
|
|
326
|
-
width - 1,
|
|
429
|
+
...this.wrapParts(
|
|
430
|
+
[`total: peak ${formatTokens(peak)}/hour`, `avg ${formatTokens(avg)}/hour`, "partial edges"],
|
|
431
|
+
inner,
|
|
327
432
|
),
|
|
328
433
|
);
|
|
329
434
|
return { lines: out, colors };
|
|
@@ -331,41 +436,36 @@ export class StatsView implements Component {
|
|
|
331
436
|
const from = rangeStart(this.range, this.today) ?? dayKey(ranged[0]?.ms ?? Date.now());
|
|
332
437
|
const series = dailySeries(ranged, from, this.today, by === "provider" ? (c) => c.provider : modelName);
|
|
333
438
|
const byDay = new Map(series.map((p) => [p.day, top.map((k) => p.byKey.get(k.key) ?? 0)]));
|
|
334
|
-
// Same total width as the heatmap above it: its day labels + weeks == the y gutter + plot.
|
|
335
|
-
const plotWidth = Math.max(MIN_PLOT_WIDTH, DAY_LABEL_WIDTH + weeksFor(contentWidth(width)) - GUTTER);
|
|
336
439
|
const bucketed = bucketize(from, this.today, (d) => byDay.get(d) ?? [], top.length, plotWidth + 1);
|
|
337
|
-
out
|
|
440
|
+
const out = head(`Tokens per ${bucketed.unit}`);
|
|
338
441
|
if (series.length < 2) {
|
|
339
442
|
out.push(this.paint("muted", "Not enough data for a chart yet"));
|
|
340
|
-
|
|
341
|
-
const p = plot(
|
|
342
|
-
top.map((k, i) => ({
|
|
343
|
-
values: bucketed.buckets.map((b) => b.values[i] ?? 0),
|
|
344
|
-
color: colors.get(k.key) ?? 0,
|
|
345
|
-
})),
|
|
346
|
-
{
|
|
347
|
-
width: plotWidth,
|
|
348
|
-
height,
|
|
349
|
-
paint: (color, text) =>
|
|
350
|
-
color === "axis" ? this.paint("muted", text) : this.paintSeries(color, top.length, text),
|
|
351
|
-
},
|
|
352
|
-
);
|
|
353
|
-
out.push(...p.lines, this.paint("muted", xAxis(bucketed.buckets, plotWidth, p.gutter)));
|
|
354
|
-
let peak = series[0];
|
|
355
|
-
for (const point of series) if (point.total > (peak?.total ?? 0)) peak = point;
|
|
356
|
-
const avg = series.reduce((n, point) => n + point.total, 0) / series.length;
|
|
357
|
-
out.push(
|
|
358
|
-
this.paint(
|
|
359
|
-
"muted",
|
|
360
|
-
`total: peak ${formatTokens(peak?.total ?? 0)} on ${shortDate(peak?.day ?? this.today)} · avg ${formatTokens(avg)}/day`,
|
|
361
|
-
),
|
|
362
|
-
);
|
|
443
|
+
return { lines: out, colors };
|
|
363
444
|
}
|
|
445
|
+
const p = plot(
|
|
446
|
+
top.map((k, i) => ({
|
|
447
|
+
values: bucketed.buckets.map((b) => b.values[i] ?? 0),
|
|
448
|
+
color: colors.get(k.key) ?? i,
|
|
449
|
+
})),
|
|
450
|
+
{ width: plotWidth, height, paint },
|
|
451
|
+
);
|
|
452
|
+
const axis = timeAxis(
|
|
453
|
+
bucketed.buckets.map((b) => keyDate(b.start).getTime()),
|
|
454
|
+
{ kind: "day", size: bucketed.size },
|
|
455
|
+
plotWidth,
|
|
456
|
+
p.gutter,
|
|
457
|
+
);
|
|
458
|
+
out.push(...p.lines, ...axis.lines.map((l) => this.paint("muted", l)));
|
|
459
|
+
let peak = series[0];
|
|
460
|
+
for (const point of series) if (point.total > (peak?.total ?? 0)) peak = point;
|
|
461
|
+
const avg = series.reduce((n, point) => n + point.total, 0) / series.length;
|
|
364
462
|
out.push(
|
|
365
|
-
...this.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
463
|
+
...this.wrapParts(
|
|
464
|
+
[
|
|
465
|
+
`total: peak ${formatTokens(peak?.total ?? 0)} on ${shortDate(peak?.day ?? this.today)}`,
|
|
466
|
+
`avg ${formatTokens(avg)}/day`,
|
|
467
|
+
],
|
|
468
|
+
inner,
|
|
369
469
|
),
|
|
370
470
|
);
|
|
371
471
|
return { lines: out, colors };
|
|
@@ -393,20 +493,22 @@ export class StatsView implements Component {
|
|
|
393
493
|
}
|
|
394
494
|
|
|
395
495
|
/**
|
|
396
|
-
* Chart
|
|
397
|
-
*
|
|
496
|
+
* Chart and the model list, scrolled as one body like Overview so even a short pane reaches every
|
|
497
|
+
* model. `owners[i]` holds the list positions of the models drawn on line `i`.
|
|
398
498
|
*/
|
|
399
499
|
private modelsBody(width: number, room: number): { lines: string[]; owners: number[][]; count: number } {
|
|
400
500
|
const { ranged } = this.scoped();
|
|
401
501
|
const totals = modelTotals(ranged);
|
|
402
502
|
if (totals.length === 0) {
|
|
403
|
-
const lines = [this.
|
|
503
|
+
const lines = [this.paint("muted", "No usage in this range.")];
|
|
404
504
|
return { lines, owners: lines.map(() => []), count: 0 };
|
|
405
505
|
}
|
|
406
|
-
// The list scrolls under the chart
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
const
|
|
506
|
+
// The list scrolls under the chart: the chart gets what a short list leaves, at least half the pane.
|
|
507
|
+
const colors = this.chart(ranged, width, TICKS).colors;
|
|
508
|
+
const list = this.listRows(totals, contentWidth(width), colors);
|
|
509
|
+
const listLines = list.reduce((n, row) => n + row.lines.length, 0);
|
|
510
|
+
const chart = this.chart(ranged, width, chartHeight(Math.max(Math.floor(room / 2), room - listLines) - 7));
|
|
511
|
+
const head = [...chart.lines, ""];
|
|
410
512
|
return {
|
|
411
513
|
lines: [...head, ...list.flatMap((row) => row.lines)],
|
|
412
514
|
owners: [...head.map(() => []), ...list.flatMap((row) => row.lines.map(() => row.models))],
|
|
@@ -415,19 +517,75 @@ export class StatsView implements Component {
|
|
|
415
517
|
}
|
|
416
518
|
|
|
417
519
|
/**
|
|
418
|
-
*
|
|
419
|
-
*
|
|
520
|
+
* Models grouped under their provider: a table when its columns fit, one aligned line per model with a
|
|
521
|
+
* provider subtotal line above its models, else three-line cards one per row. Indented two columns.
|
|
420
522
|
*/
|
|
421
|
-
private listRows(totals: readonly ModelTotals[],
|
|
523
|
+
private listRows(totals: readonly ModelTotals[], inner: number, colors: ReadonlyMap<string, number>): ListRow[] {
|
|
422
524
|
const t = this.o.theme;
|
|
423
525
|
const grand = totals.reduce((n, m) => n + tokensOf(m), 0);
|
|
424
|
-
const twoUp = width >= TWO_COLUMN_WIDTH;
|
|
425
|
-
const inner = Math.max(1, width - 3);
|
|
426
|
-
const colWidth = twoUp ? Math.floor(inner / 2) : inner;
|
|
427
526
|
const bullet = (key: string) => {
|
|
428
527
|
const index = colors.get(key);
|
|
429
528
|
return index === undefined ? this.paint("muted", "•") : this.paintSeries(index, colors.size, "•");
|
|
430
529
|
};
|
|
530
|
+
const sumOf = (models: readonly ModelTotals[]): Totals => ({
|
|
531
|
+
input: models.reduce((n, m) => n + m.input, 0),
|
|
532
|
+
output: models.reduce((n, m) => n + m.output, 0),
|
|
533
|
+
cacheRead: models.reduce((n, m) => n + m.cacheRead, 0),
|
|
534
|
+
cacheWrite: models.reduce((n, m) => n + m.cacheWrite, 0),
|
|
535
|
+
cost: models.reduce((n, m) => n + m.cost, 0),
|
|
536
|
+
});
|
|
537
|
+
const providers = [...new Set(totals.map((m) => m.provider))].map((provider) => {
|
|
538
|
+
const models = totals.filter((m) => m.provider === provider);
|
|
539
|
+
const sub = sumOf(models);
|
|
540
|
+
return { provider, models, sub, sum: models.reduce((n, m) => n + tokensOf(m), 0) };
|
|
541
|
+
});
|
|
542
|
+
const rows: ListRow[] = [];
|
|
543
|
+
let position = 0;
|
|
544
|
+
const numbers = (m: Totals, sum: number) => [
|
|
545
|
+
formatTokens(sum),
|
|
546
|
+
percent(sum, grand),
|
|
547
|
+
formatTokens(m.input),
|
|
548
|
+
formatTokens(m.output),
|
|
549
|
+
formatTokens(m.cacheRead),
|
|
550
|
+
formatTokens(m.cacheWrite),
|
|
551
|
+
money(m.cost),
|
|
552
|
+
];
|
|
553
|
+
const heads = ["Tokens", "Share", "In", "Out", "Cache read", "Cache write", "Cost (notional)"];
|
|
554
|
+
const nameWidth = Math.max(
|
|
555
|
+
...totals.map((m) => visibleWidth(sanitize(m.model)) + 4),
|
|
556
|
+
...providers.map((p) => visibleWidth(sanitize(p.provider)) + 2),
|
|
557
|
+
);
|
|
558
|
+
const widths = heads.map((h, c) =>
|
|
559
|
+
Math.max(
|
|
560
|
+
h.length,
|
|
561
|
+
...totals.map((m) => (numbers(m, tokensOf(m))[c] ?? "").length),
|
|
562
|
+
...providers.map((p) => (numbers(p.sub, p.sum)[c] ?? "").length),
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
const tableWidth = 2 + nameWidth + widths.reduce((n, x) => n + x + 2, 0);
|
|
566
|
+
if (tableWidth <= inner) {
|
|
567
|
+
const line = (name: string, cells: readonly string[], paint: (s: string) => string) =>
|
|
568
|
+
` ${padTo(name, nameWidth)}${cells.map((cell, c) => paint(cell.padStart((widths[c] ?? 0) + 2))).join("")}`;
|
|
569
|
+
rows.push({ lines: [line("", heads, (s) => this.paint("muted", s))], models: [] });
|
|
570
|
+
for (const { provider, models, sub, sum } of providers) {
|
|
571
|
+
rows.push({
|
|
572
|
+
lines: [line(`${bullet(provider)} ${t.bold(sanitize(provider))}`, numbers(sub, sum), (s) => t.bold(s))],
|
|
573
|
+
models: [],
|
|
574
|
+
});
|
|
575
|
+
for (const m of models)
|
|
576
|
+
rows.push({
|
|
577
|
+
lines: [
|
|
578
|
+
line(
|
|
579
|
+
`${colors.has(m.name) ? ` ${bullet(m.name)} ` : " "}${sanitize(m.model)}`,
|
|
580
|
+
numbers(m, tokensOf(m)),
|
|
581
|
+
(s) => this.paint("muted", s),
|
|
582
|
+
),
|
|
583
|
+
],
|
|
584
|
+
models: [position++],
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return rows;
|
|
588
|
+
}
|
|
431
589
|
const entry = (m: ModelTotals): string[] => [
|
|
432
590
|
`${bullet(m.name)} ${t.bold(sanitize(m.model))} ${this.paint("muted", `(${percent(tokensOf(m), grand)})`)}`,
|
|
433
591
|
this.paint("muted", ` In: ${formatTokens(m.input)} · Out: ${formatTokens(m.output)}`),
|
|
@@ -436,40 +594,34 @@ export class StatsView implements Component {
|
|
|
436
594
|
` Cache: ${formatTokens(m.cacheRead)} read · ${formatTokens(m.cacheWrite)} write · ${money(m.cost)} notional`,
|
|
437
595
|
),
|
|
438
596
|
];
|
|
439
|
-
const
|
|
440
|
-
let position = 0;
|
|
441
|
-
for (const provider of [...new Set(totals.map((m) => m.provider))]) {
|
|
442
|
-
const models = totals.filter((m) => m.provider === provider).map((m) => ({ m, i: position++ }));
|
|
443
|
-
const sum = models.reduce((n, { m }) => n + tokensOf(m), 0);
|
|
597
|
+
for (const { provider, models, sum } of providers) {
|
|
444
598
|
rows.push({
|
|
445
599
|
lines: [
|
|
446
600
|
`${bullet(provider)} ${t.bold(sanitize(provider))} ${formatTokens(sum)} ${this.paint("muted", `(${percent(sum, grand)})`)}`,
|
|
447
601
|
],
|
|
448
602
|
models: [],
|
|
449
603
|
});
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
blocks
|
|
456
|
-
.map((b, col) => {
|
|
457
|
-
const cell = truncateToWidth(
|
|
458
|
-
b[line] ?? "",
|
|
459
|
-
Math.max(1, colWidth - (col < blocks.length - 1 ? 2 : 0)),
|
|
460
|
-
"…",
|
|
461
|
-
);
|
|
462
|
-
return col < blocks.length - 1 ? cell + " ".repeat(Math.max(0, colWidth - visibleWidth(cell))) : cell;
|
|
463
|
-
})
|
|
464
|
-
.join(""),
|
|
465
|
-
);
|
|
466
|
-
rows.push({ lines: lines.map((l) => ` ${l}`), models: group.map(({ i }) => i) });
|
|
467
|
-
}
|
|
604
|
+
for (const m of models)
|
|
605
|
+
rows.push({
|
|
606
|
+
lines: entry(m).map((l) => ` ${truncateToWidth(l, Math.max(1, inner - 2), "…")}`),
|
|
607
|
+
models: [position++],
|
|
608
|
+
});
|
|
468
609
|
}
|
|
469
610
|
return rows;
|
|
470
611
|
}
|
|
471
612
|
}
|
|
472
613
|
|
|
614
|
+
/** `text` padded with spaces to `width` columns (ANSI-aware); never cuts. */
|
|
615
|
+
function padTo(text: string, width: number): string {
|
|
616
|
+
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** `left` and `right` on one `width`-column line, right flush; `undefined` if they would touch. */
|
|
620
|
+
function spread(left: string, right: string, width: number): string | undefined {
|
|
621
|
+
const gap = width - visibleWidth(left) - visibleWidth(right);
|
|
622
|
+
return gap >= 3 ? `${left}${" ".repeat(gap)}${right}` : undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
473
625
|
/** Plain-text per-model table for print/json/rpc modes, all time. */
|
|
474
626
|
export function summary(calls: readonly Call[]): string {
|
|
475
627
|
const totals = modelTotals(calls);
|
package/src/stats/days.ts
CHANGED
|
@@ -35,6 +35,13 @@ export function dayRange(from: string, to: string): string[] {
|
|
|
35
35
|
return out;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
|
|
39
|
+
|
|
40
|
+
/** `Mon` for `Date#getDay()` 1. */
|
|
41
|
+
export function weekdayName(day: number): string {
|
|
42
|
+
return WEEKDAYS[day] ?? "";
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
export function monthName(month: number): string {
|
|
39
46
|
return MONTHS[month] ?? "";
|
|
40
47
|
}
|