@po.dev/pi-usage-dashboard 0.1.2 → 0.1.4

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.
Files changed (2) hide show
  1. package/index.ts +80 -27
  2. package/package.json +1 -1
package/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { DynamicBorder } from "@earendil-works/pi-coding-agent";
14
14
  import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
+ import type { TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
15
16
 
16
17
  import { AUXILIARY_PROVIDER, collectUsageData, getAgentDir, splitHourlyKey, TAB_ORDER } from "./data";
17
18
  import type { CollectProgress } from "./data";
@@ -378,6 +379,7 @@ class UsageComponent {
378
379
  private tableFilterEditing = false;
379
380
  private graphHidden = new Set<string>();
380
381
  private graphLegendIndex = 0;
382
+ private graphDetailProvider: string | null = null;
381
383
  private promptSections: PromptSection[];
382
384
  private insightIndex = 0;
383
385
  private insightDetail: PromptSection | null = null;
@@ -599,7 +601,10 @@ class UsageComponent {
599
601
  } else if (matchesKey(data, "down")) {
600
602
  const count = this.buildDailyProviderModel().providers.length;
601
603
  this.graphLegendIndex = Math.min(Math.max(count - 1, 0), this.graphLegendIndex + 1);
602
- } else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
604
+ } else if (matchesKey(data, "enter")) {
605
+ const target = this.buildDailyProviderModel().providers[this.graphLegendIndex];
606
+ if (target) this.graphDetailProvider = this.graphDetailProvider === target.name ? null : target.name;
607
+ } else if (matchesKey(data, "space")) {
603
608
  const target = this.buildDailyProviderModel().providers[this.graphLegendIndex];
604
609
  if (target) {
605
610
  if (this.graphHidden.has(target.name)) this.graphHidden.delete(target.name);
@@ -656,7 +661,7 @@ class UsageComponent {
656
661
  let content: string;
657
662
  const stats = this.data[this.activeTab];
658
663
  if (this.viewMode === "graph") {
659
- const slice = `${this.graphCumulative ? "cumulative" : "per-bucket"}-${this.graphMetric}-by-${this.graphGroupBy}`;
664
+ const slice = `${this.viewMode}-${this.graphCumulative ? "cumulative" : "per-bucket"}-${this.graphMetric}-by-${this.graphGroupBy}`;
660
665
  name = exportFileName("graph", this.activeTab, slice, "csv", now);
661
666
  content = buildGraphCsv(this.buildGraphModelForView());
662
667
  } else if (this.viewMode === "insights") {
@@ -750,45 +755,68 @@ class UsageComponent {
750
755
  return [truncateToWidth(summary, width), truncateToWidth(stats, width), ""];
751
756
  }
752
757
 
753
- private buildDailyProviderModel(): { days: { label: string; total: number; providers: Map<string, number> }[]; providers: { name: string; total: number }[]; max: number; total: number } {
758
+ private buildDailyProviderModel(): { days: { label: string; total: number; providers: Map<string, number> }[]; providers: { name: string; total: number; models: { name: string; cost: number; tokens: number; value: number }[] }[]; max: number; total: number } {
754
759
  const now = this.data.bounds.nowMs;
755
760
  const start = this.activeTab === "today" ? this.data.bounds.todayMs : this.activeTab === "thisWeek" ? this.data.bounds.weekStartMs : this.activeTab === "lastWeek" ? this.data.bounds.lastWeekStartMs : this.activeTab === "last30Days" ? this.data.bounds.last30DaysStartMs : Math.min(...this.data.hourly.keys(), this.data.bounds.todayMs);
756
761
  const end = this.activeTab === "lastWeek" ? this.data.bounds.weekStartMs : now;
757
762
  const dayMs = 24 * 3_600_000;
763
+ const valueOf = (cell: { cost: number; input: number; output: number; cacheRead: number; cacheWrite: number }) => cell.cost;
758
764
  const days: { label: string; total: number; providers: Map<string, number> }[] = [];
759
- for (let t = start; t < end; t += dayMs) days.push({ label: new Date(t).toLocaleDateString(undefined, { day: "numeric", month: "short" }), total: 0, providers: new Map() });
765
+ for (let t = start; t < end; t += dayMs) {
766
+ const date = new Date(t);
767
+ days.push({ label: `${date.getMonth() + 1}/${date.getDate()}`, total: 0, providers: new Map() });
768
+ }
760
769
  const providerTotals = new Map<string, number>();
770
+ const modelTotals = new Map<string, Map<string, { cost: number; tokens: number; value: number }>>();
761
771
  for (const [hour, cells] of this.data.hourly) {
762
772
  if (hour < start || hour >= end) continue;
763
773
  const day = days[Math.min(days.length - 1, Math.floor((hour - start) / dayMs))];
764
774
  if (!day) continue;
765
775
  for (const [key, cell] of cells) {
766
- const provider = splitHourlyKey(key).provider;
776
+ const { provider, model } = splitHourlyKey(key);
767
777
  if (provider === AUXILIARY_PROVIDER) continue;
768
- day.providers.set(provider, (day.providers.get(provider) ?? 0) + cell.cost);
769
- day.total += cell.cost;
770
- providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + cell.cost);
778
+ const tokens = cell.input + cell.output + cell.cacheRead + cell.cacheWrite;
779
+ const value = valueOf(cell);
780
+ day.providers.set(provider, (day.providers.get(provider) ?? 0) + value);
781
+ day.total += value;
782
+ providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + value);
783
+ let byModel = modelTotals.get(provider);
784
+ if (!byModel) {
785
+ byModel = new Map();
786
+ modelTotals.set(provider, byModel);
787
+ }
788
+ const modelTotal = byModel.get(model) ?? { cost: 0, tokens: 0, value: 0 };
789
+ modelTotal.cost += cell.cost;
790
+ modelTotal.tokens += tokens;
791
+ modelTotal.value += value;
792
+ byModel.set(model, modelTotal);
771
793
  }
772
794
  }
773
- const providers = [...providerTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([name, total]) => ({ name, total }));
795
+ const providers = [...providerTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([name, total]) => ({
796
+ name,
797
+ total,
798
+ models: [...(modelTotals.get(name) ?? new Map()).entries()].sort((a, b) => b[1].value - a[1].value).map(([modelName, stats]) => ({ name: modelName, ...stats })),
799
+ }));
774
800
  return { days, providers, max: Math.max(0, ...days.map((d) => d.total)), total: days.reduce((sum, d) => sum + d.total, 0) };
775
801
  }
776
802
 
777
803
  private renderGraph(width: number): string[] {
778
804
  const th = this.theme;
779
805
  const model = this.buildDailyProviderModel();
806
+ const formatValue = formatAxisCost;
780
807
  const lines: string[] = [...this.renderOverviewSummary(width), th.fg("muted", "Daily total cost · by provider"), ""];
781
808
  if (model.total === 0) return [...lines, th.fg("dim", " No usage data for this period"), ""];
782
809
 
783
- const labelW = Math.max(formatAxisCost(model.max).length, 3);
784
- const plotW = Math.max(10, Math.min(Math.floor((width - labelW - 2) / 2), model.days.length));
810
+ const labelW = Math.max(formatValue(model.max).length, 3);
811
+ const slotW = 6;
812
+ const plotW = Math.max(1, Math.min(Math.floor((width - labelW - 2) / slotW), model.days.length));
785
813
  const start = Math.max(0, model.days.length - plotW);
786
814
  const days = model.days.slice(start);
787
- const axisW = Math.max(0, days.length * 2 - 1);
815
+ const axisW = Math.max(0, days.length * slotW - 1);
788
816
  const height = 8;
789
817
  for (let row = height; row >= 1; row--) {
790
818
  const threshold = (model.max * row) / height;
791
- let line = th.fg("dim", `${row === height ? formatAxisCost(model.max) : row === 1 ? "$0" : ""}`.padStart(labelW) + " │");
819
+ let line = th.fg("dim", `${row === height ? formatValue(model.max) : row === 1 ? formatValue(0) : ""}`.padStart(labelW) + " │");
792
820
  for (let d = 0; d < days.length; d++) {
793
821
  const day = days[d]!;
794
822
  let acc = 0, owner = -1;
@@ -798,18 +826,29 @@ class UsageComponent {
798
826
  acc += day.providers.get(p.name) ?? 0;
799
827
  if (acc >= threshold) { owner = i; break; }
800
828
  }
801
- line += (owner < 0 ? " " : seriesColor(owner) + "█" + COLOR_RESET) + (d === days.length - 1 ? "" : " ");
829
+ line += (owner < 0 ? " " : seriesColor(owner) + "█" + COLOR_RESET) + " ".repeat(d === days.length - 1 ? 0 : slotW - 1);
802
830
  }
803
831
  lines.push(line);
804
832
  }
805
- lines.push(th.fg("dim", " ".repeat(labelW) + " └" + "─".repeat(axisW)));
806
- lines.push(th.fg("dim", " ".repeat(labelW + 2) + (days[0]?.label ?? "") + " ".repeat(Math.max(1, axisW - visibleWidth((days[0]?.label ?? "") + (days.at(-1)?.label ?? "")))) + (days.at(-1)?.label ?? "")));
833
+ lines.push(th.fg("dim", " ".repeat(labelW) + " └" + Array.from({ length: axisW }, (_, i) => i % slotW === 0 ? "┬" : "─").join("")));
834
+ lines.push(th.fg("dim", " ".repeat(labelW + 2) + days.map((day) => day.label.padEnd(slotW)).join("").trimEnd()));
807
835
  lines.push("");
836
+ const detail = model.providers.find((p) => p.name === this.graphDetailProvider);
837
+ const rows = detail?.models.slice(0, 8) ?? [];
838
+ const prefix = " ";
839
+ const costWidth = Math.max(visibleWidth("Cost ($)"), ...rows.map((m) => visibleWidth(formatAxisCost(m.cost))));
840
+ const tokenWidth = Math.max(visibleWidth("Token usage"), ...rows.map((m) => visibleWidth(formatTokens(m.tokens))));
841
+ const modelWidth = Math.max(visibleWidth("Model"), Math.min(Math.max(visibleWidth("Model"), ...rows.map((m) => visibleWidth(m.name))), width - visibleWidth(prefix) - costWidth - tokenWidth - 3));
808
842
  for (let i = 0; i < model.providers.length; i++) {
809
843
  const p = model.providers[i]!;
810
844
  const cursor = i === this.graphLegendIndex ? th.fg("accent", "▸ ") : " ";
811
845
  const marker = this.graphHidden.has(p.name) ? th.fg("dim", "·") : seriesColor(i) + "•" + COLOR_RESET;
812
- lines.push(`${cursor}${marker} ${padRight(this.graphHidden.has(p.name) ? th.fg("dim", p.name) : p.name, 24)} ${padLeft(formatAxisCost(p.total), 8)}`);
846
+ lines.push(`${cursor}${marker} ${padRight(this.graphHidden.has(p.name) ? th.fg("dim", p.name) : p.name, 24)} ${padLeft(formatValue(p.total), 8)}`);
847
+ if (p !== detail) continue;
848
+ lines.push(this.theme.fg("muted", `${prefix}${padRight("Model", modelWidth)} ${padLeft("Cost ($)", costWidth)} ${padLeft("Token usage", tokenWidth)}`));
849
+ for (const m of rows) {
850
+ lines.push(`${prefix}${padRight(truncateToWidth(m.name, modelWidth), modelWidth)} ${padLeft(formatAxisCost(m.cost), costWidth)} ${padLeft(formatTokens(m.tokens), tokenWidth)}`);
851
+ }
813
852
  }
814
853
  lines.push("");
815
854
  return lines;
@@ -1100,23 +1139,23 @@ class UsageComponent {
1100
1139
  const variants =
1101
1140
  this.viewMode === "graph"
1102
1141
  ? [
1103
- "[Tab/←→] period [↑↓/Enter] provider filter [a] all [e] export [v] view [q] close",
1104
- "[Tab] period [↑↓/Enter] filter [e] export [v] view [q] close",
1105
- "[↑↓] filter [v] view [q] close",
1142
+ "[Tab] view [←→] period [↑↓] select [Enter/click] models [Space] hide [e] export [q] close",
1143
+ "[←→] period [↑↓] select [Enter] models [q] close",
1144
+ "[Enter] models [q] close",
1106
1145
  "[q] close",
1107
1146
  ]
1108
1147
  : this.viewMode === "insights"
1109
1148
  ? [
1110
- "[Tab/←→] period [↑↓] select [e] export [v] view [q] close",
1111
- "[Tab] period [↑↓] select [e] export [v] view [q] close",
1112
- "[↑↓] select [v] view [q] close",
1149
+ "[Tab] view [←→] period [↑↓] select [e] export [q] close",
1150
+ "[←→] period [↑↓] select [e] export [q] close",
1151
+ "[↑↓] select [q] close",
1113
1152
  "[q] close",
1114
1153
  ]
1115
1154
  : [
1116
- "[Tab/←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [v] view [q] close",
1117
- "[Tab] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [v] view [q] close",
1118
- "[↑↓] select [Enter] expand [/] filter [x] hide [v] view [q] close",
1119
- "[↑↓] select [/] [x] [v] [q]",
1155
+ "[Tab] view [←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [q] close",
1156
+ "[←→] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [q] close",
1157
+ "[↑↓] select [Enter] expand [/] filter [x] hide [q] close",
1158
+ "[↑↓] select [/] [x] [q]",
1120
1159
  "[↑↓] select [q] close",
1121
1160
  "[q] close",
1122
1161
  ];
@@ -1124,6 +1163,19 @@ class UsageComponent {
1124
1163
  return [...noteLines, this.theme.fg("dim", line)];
1125
1164
  }
1126
1165
 
1166
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
1167
+ if (this.viewMode !== "graph" || event.type !== "click" || event.button !== "left") return undefined;
1168
+ const legendStart = 20;
1169
+ const idx = event.y - legendStart;
1170
+ const providers = this.buildDailyProviderModel().providers;
1171
+ if (idx < 0 || idx >= providers.length) return undefined;
1172
+ const target = providers[idx]!;
1173
+ this.graphLegendIndex = idx;
1174
+ this.graphDetailProvider = this.graphDetailProvider === target.name ? null : target.name;
1175
+ this.requestRender();
1176
+ return { handled: true, render: true };
1177
+ }
1178
+
1127
1179
  invalidate(): void {}
1128
1180
  dispose(): void {}
1129
1181
  }
@@ -1239,6 +1291,7 @@ export default function (pi: ExtensionAPI) {
1239
1291
  },
1240
1292
  invalidate: () => container.invalidate(),
1241
1293
  handleInput: (input: string) => usage.handleInput(input),
1294
+ handleMouse: (event: TuiMouseEvent) => usage.handleMouse({ ...event, y: event.y - 3 }),
1242
1295
  dispose: () => {},
1243
1296
  };
1244
1297
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@po.dev/pi-usage-dashboard",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Local token and cost dashboard for pi sessions",
5
5
  "keywords": [
6
6
  "pi-package",