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

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 +82 -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,31 @@ 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("");
808
836
  for (let i = 0; i < model.providers.length; i++) {
809
837
  const p = model.providers[i]!;
810
838
  const cursor = i === this.graphLegendIndex ? th.fg("accent", "▸ ") : " ";
811
839
  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)}`);
840
+ lines.push(`${cursor}${marker} ${padRight(this.graphHidden.has(p.name) ? th.fg("dim", p.name) : p.name, 24)} ${padLeft(formatValue(p.total), 8)}`);
841
+ }
842
+ const detail = model.providers.find((p) => p.name === this.graphDetailProvider);
843
+ if (detail) {
844
+ const rows = detail.models.slice(0, 8);
845
+ const prefix = " · ";
846
+ const costWidth = Math.max(visibleWidth("Cost ($)"), ...rows.map((m) => visibleWidth(formatAxisCost(m.cost))));
847
+ const tokenWidth = Math.max(visibleWidth("Token usage"), ...rows.map((m) => visibleWidth(formatTokens(m.tokens))));
848
+ const modelWidth = Math.max(visibleWidth("Model"), Math.min(Math.max(...rows.map((m) => visibleWidth(m.name))), width - visibleWidth(prefix) - costWidth - tokenWidth - 3));
849
+ lines.push("", th.fg("accent", `${detail.name} by model`));
850
+ lines.push(this.theme.fg("muted", `${prefix}${padRight("Model", modelWidth)} ${padLeft("Cost ($)", costWidth)} ${padLeft("Token usage", tokenWidth)}`));
851
+ for (const m of rows) {
852
+ lines.push(`${prefix}${padRight(truncateToWidth(m.name, modelWidth), modelWidth)} ${padLeft(formatAxisCost(m.cost), costWidth)} ${padLeft(formatTokens(m.tokens), tokenWidth)}`);
853
+ }
813
854
  }
814
855
  lines.push("");
815
856
  return lines;
@@ -1100,23 +1141,23 @@ class UsageComponent {
1100
1141
  const variants =
1101
1142
  this.viewMode === "graph"
1102
1143
  ? [
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",
1144
+ "[Tab] view [←→] period [↑↓] select [Enter/click] models [Space] hide [e] export [q] close",
1145
+ "[←→] period [↑↓] select [Enter] models [q] close",
1146
+ "[Enter] models [q] close",
1106
1147
  "[q] close",
1107
1148
  ]
1108
1149
  : this.viewMode === "insights"
1109
1150
  ? [
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",
1151
+ "[Tab] view [←→] period [↑↓] select [e] export [q] close",
1152
+ "[←→] period [↑↓] select [e] export [q] close",
1153
+ "[↑↓] select [q] close",
1113
1154
  "[q] close",
1114
1155
  ]
1115
1156
  : [
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]",
1157
+ "[Tab] view [←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [q] close",
1158
+ "[←→] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [q] close",
1159
+ "[↑↓] select [Enter] expand [/] filter [x] hide [q] close",
1160
+ "[↑↓] select [/] [x] [q]",
1120
1161
  "[↑↓] select [q] close",
1121
1162
  "[q] close",
1122
1163
  ];
@@ -1124,6 +1165,19 @@ class UsageComponent {
1124
1165
  return [...noteLines, this.theme.fg("dim", line)];
1125
1166
  }
1126
1167
 
1168
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
1169
+ if (this.viewMode !== "graph" || event.type !== "click" || event.button !== "left") return undefined;
1170
+ const legendStart = 20;
1171
+ const idx = event.y - legendStart;
1172
+ const providers = this.buildDailyProviderModel().providers;
1173
+ if (idx < 0 || idx >= providers.length) return undefined;
1174
+ const target = providers[idx]!;
1175
+ this.graphLegendIndex = idx;
1176
+ this.graphDetailProvider = this.graphDetailProvider === target.name ? null : target.name;
1177
+ this.requestRender();
1178
+ return { handled: true, render: true };
1179
+ }
1180
+
1127
1181
  invalidate(): void {}
1128
1182
  dispose(): void {}
1129
1183
  }
@@ -1239,6 +1293,7 @@ export default function (pi: ExtensionAPI) {
1239
1293
  },
1240
1294
  invalidate: () => container.invalidate(),
1241
1295
  handleInput: (input: string) => usage.handleInput(input),
1296
+ handleMouse: (event: TuiMouseEvent) => usage.handleMouse({ ...event, y: event.y - 3 }),
1242
1297
  dispose: () => {},
1243
1298
  };
1244
1299
  });
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.3",
4
4
  "description": "Local token and cost dashboard for pi sessions",
5
5
  "keywords": [
6
6
  "pi-package",