@nredd/pi-session-kit 0.4.0 → 0.6.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.
@@ -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, xAxis } from "./chart.ts";
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
- const inner = Math.max(1, paneWidth - 1);
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
- /** Model list goes two-up from this width. */
68
- const TWO_COLUMN_WIDTH = 80;
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 = [this.tabsLine(), this.paint("muted", this.o.coverage ?? "local-only · never synced")];
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
- private selectors(): string {
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
- return `${ranges} ${this.paint("muted", "filter:")} ${t.bold(sanitize(filterLabel(this.filter)))}`;
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 t = this.o.theme;
326
+ const inner = contentWidth(width);
234
327
  const out: string[] = [];
235
328
  const daily = dailyTotals(all);
236
- if (!isHourly(this.range))
237
- out.push(
238
- ...heatmap(daily, this.today, contentWidth(width), (color, text) =>
239
- color === "heat" ? this.paint("accent", text) : this.paint(color === "empty" ? "dim" : "muted", text),
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
- out.push("", this.selectors(), "");
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
- * "Tokens per <bucket>", x-axis, peak/average caption and legend. One line per provider under
283
- * the "all" filter, else one per model; top three.
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 out: string[] = [];
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
- if (isHourly(this.range)) {
295
- const series = hourlySeries(ranged, this.range, this.now, by === "provider" ? (c) => c.provider : modelName);
296
- const plotWidth = Math.max(MIN_PLOT_WIDTH, contentWidth(width) - GUTTER);
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
- out.push(t.bold("Tokens per hour") + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`), ...p.lines);
307
- out.push(this.paint("muted", `From: ${series[0]?.day}`), this.paint("muted", `To: ${series.at(-1)?.day}`));
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.paint("muted", `total: peak ${formatTokens(peak)}/hour · avg ${formatTokens(avg)}/bucket · partial edges`),
321
- );
322
- out.push(
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.push(t.bold(`Tokens per ${bucketed.unit}`) + this.paint("muted", ` ${TOKENS_NOTE} · by ${by}`));
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
- } else {
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.legend(
366
- top.map((k) => k.key),
367
- colors,
368
- width - 1,
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, range line and the model list, scrolled as one body like Overview so even a short pane
397
- * reaches every model. `owners[i]` holds the list positions of the models drawn on line `i`.
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.selectors(), "", this.paint("muted", "No usage in this range.")];
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, so the chart gets about half the pane.
407
- const chart = this.chart(ranged, width, chartHeight(Math.floor(room / 2) - 5));
408
- const head = [...chart.lines, "", this.selectors(), ""];
409
- const list = this.listRows(totals, width, chart.colors);
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
- * Provider headers, then that provider's models: two per row when the pane is `TWO_COLUMN_WIDTH`
419
- * or wider, else one. Rows are indented two columns inside the overlay's one-column margin.
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[], width: number, colors: ReadonlyMap<string, number>): ListRow[] {
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 rows: ListRow[] = [];
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 per = twoUp ? 2 : 1;
451
- for (let k = 0; k < models.length; k += per) {
452
- const group = models.slice(k, k + per);
453
- const blocks = group.map(({ m }) => entry(m));
454
- const lines = [0, 1, 2].map((line) =>
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
  }
@@ -8,6 +8,10 @@ import { addDays, keyDate, monthName } from "./days.ts";
8
8
 
9
9
  /** A year: the heatmap shows at least this many weeks whenever the pane fits them. */
10
10
  export const YEAR_WEEKS = 52;
11
+ /** Two years: older weeks are dead space, so wide panes stop here. */
12
+ export const MAX_WEEKS = 104;
13
+ /** Columns the newest month's name can run past the last week: callers leave this much room. */
14
+ export const MONTH_OVERFLOW = 2;
11
15
  export const DAY_LABEL_WIDTH = 4;
12
16
  const LEVEL_CHARS = ["·", "░", "▒", "▓", "█"] as const;
13
17
 
@@ -50,9 +54,12 @@ export function monthRow(start: string, weeks: number): string {
50
54
  return row.join("").trimEnd();
51
55
  }
52
56
 
53
- /** How many week columns fit `width`: one per column after the day labels, so wider shows more history. */
57
+ /**
58
+ * How many week columns fit `width`: one per column after the day labels and the room the newest
59
+ * month's name may run into, up to `MAX_WEEKS`.
60
+ */
54
61
  export function weeksFor(width: number): number {
55
- return Math.max(1, width - DAY_LABEL_WIDTH);
62
+ return Math.min(MAX_WEEKS, Math.max(1, width - DAY_LABEL_WIDTH - MONTH_OVERFLOW));
56
63
  }
57
64
 
58
65
  /**