@po.dev/pi-usage-dashboard 0.1.1 → 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 +91 -30
  2. package/package.json +17 -4
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,70 @@ 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(width - labelW - 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);
815
+ const axisW = Math.max(0, days.length * slotW - 1);
787
816
  const height = 8;
788
817
  for (let row = height; row >= 1; row--) {
789
818
  const threshold = (model.max * row) / height;
790
- let line = th.fg("dim", `${row === height ? formatAxisCost(model.max) : row === 1 ? "$0" : ""}`.padStart(labelW) + " │");
791
- for (const day of days) {
819
+ let line = th.fg("dim", `${row === height ? formatValue(model.max) : row === 1 ? formatValue(0) : ""}`.padStart(labelW) + " │");
820
+ for (let d = 0; d < days.length; d++) {
821
+ const day = days[d]!;
792
822
  let acc = 0, owner = -1;
793
823
  for (let i = 0; i < model.providers.length; i++) {
794
824
  const p = model.providers[i]!;
@@ -796,18 +826,31 @@ class UsageComponent {
796
826
  acc += day.providers.get(p.name) ?? 0;
797
827
  if (acc >= threshold) { owner = i; break; }
798
828
  }
799
- line += owner < 0 ? " " : seriesColor(owner) + "█" + COLOR_RESET;
829
+ line += (owner < 0 ? " " : seriesColor(owner) + "█" + COLOR_RESET) + " ".repeat(d === days.length - 1 ? 0 : slotW - 1);
800
830
  }
801
831
  lines.push(line);
802
832
  }
803
- lines.push(th.fg("dim", " ".repeat(labelW) + " └" + "─".repeat(days.length)));
804
- lines.push(th.fg("dim", " ".repeat(labelW + 2) + (days[0]?.label ?? "") + " ".repeat(Math.max(1, days.length - 12)) + (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()));
805
835
  lines.push("");
806
836
  for (let i = 0; i < model.providers.length; i++) {
807
837
  const p = model.providers[i]!;
808
838
  const cursor = i === this.graphLegendIndex ? th.fg("accent", "▸ ") : " ";
809
839
  const marker = this.graphHidden.has(p.name) ? th.fg("dim", "·") : seriesColor(i) + "•" + COLOR_RESET;
810
- 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
+ }
811
854
  }
812
855
  lines.push("");
813
856
  return lines;
@@ -824,11 +867,14 @@ class UsageComponent {
824
867
  const total = detail ? detail.chars : this.promptSections.reduce((sum, section) => sum + section.chars, 0);
825
868
  const source = this.historySelected ? `History ${this.historySelected.slice(0, 8)}` : "Current session";
826
869
  const lines = [th.bold(detail ? `${detail.label} sources` : `${source} prompt sources`), th.fg("dim", detail ? "Esc back · source sizes" : "Assembled system prompt · source sizes · Enter details"), ""];
870
+ const colors: ("accent" | "success" | "warning" | "thinkingHigh")[] = ["accent", "success", "warning", "thinkingHigh"];
827
871
  if (!detail) {
828
872
  const widthBar = Math.max(20, Math.min(width - 4, 60));
829
- const colors: ("accent" | "success" | "warning" | "thinkingHigh")[] = ["accent", "success", "warning", "thinkingHigh"];
830
873
  let bar = "";
831
- for (let i = 0; i < rows.length; i++) bar += th.fg(colors[i % colors.length]!, "█".repeat(Math.round(rows[i]!.chars / Math.max(total, 1) * widthBar)));
874
+ for (let i = 0; i < rows.length; i++) {
875
+ if (i > 0) bar += " ";
876
+ bar += th.fg(colors[i % colors.length]!, "▬".repeat(Math.round(rows[i]!.chars / Math.max(total, 1) * widthBar)));
877
+ }
832
878
  lines.push(bar, "");
833
879
  }
834
880
  for (let i = 0; i < rows.length; i++) {
@@ -837,8 +883,9 @@ class UsageComponent {
837
883
  const pct = total ? `${(chars / total * 100).toFixed(1)}%` : "0.0%";
838
884
  const selected = i === this.insightIndex;
839
885
  const marker = selected ? th.fg("accent", "▸ ") : th.fg("dim", "· ");
886
+ const indicator = detail ? "" : th.fg(colors[i % colors.length]!, "■ ");
840
887
  const suffix = th.fg("dim", `${formatNumber(chars)} chars ${pct}`);
841
- lines.push(`${marker}${selected ? th.fg("accent", row.label) : row.label}${" ".repeat(Math.max(1, width - visibleWidth(marker + row.label + suffix)))}${suffix}`);
888
+ lines.push(`${marker}${indicator}${selected ? th.fg("accent", row.label) : row.label}${" ".repeat(Math.max(1, width - visibleWidth(marker + indicator + row.label + suffix)))}${suffix}`);
842
889
  }
843
890
  lines.push("", th.fg("dim", "[↑↓] select [Enter] open [Esc] back"));
844
891
  return lines;
@@ -1094,23 +1141,23 @@ class UsageComponent {
1094
1141
  const variants =
1095
1142
  this.viewMode === "graph"
1096
1143
  ? [
1097
- "[Tab/←→] period [↑↓/Enter] provider filter [a] all [e] export [v] view [q] close",
1098
- "[Tab] period [↑↓/Enter] filter [e] export [v] view [q] close",
1099
- "[↑↓] 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",
1100
1147
  "[q] close",
1101
1148
  ]
1102
1149
  : this.viewMode === "insights"
1103
1150
  ? [
1104
- "[Tab/←→] period [↑↓] select [e] export [v] view [q] close",
1105
- "[Tab] period [↑↓] select [e] export [v] view [q] close",
1106
- "[↑↓] 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",
1107
1154
  "[q] close",
1108
1155
  ]
1109
1156
  : [
1110
- "[Tab/←→] period [↑↓] select [Enter] expand [/] filter [x] hide [a] all [e] export [v] view [q] close",
1111
- "[Tab] period [↑↓] select [Enter] expand [/] filter [x] hide [e] export [v] view [q] close",
1112
- "[↑↓] select [Enter] expand [/] filter [x] hide [v] view [q] close",
1113
- "[↑↓] 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]",
1114
1161
  "[↑↓] select [q] close",
1115
1162
  "[q] close",
1116
1163
  ];
@@ -1118,6 +1165,19 @@ class UsageComponent {
1118
1165
  return [...noteLines, this.theme.fg("dim", line)];
1119
1166
  }
1120
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
+
1121
1181
  invalidate(): void {}
1122
1182
  dispose(): void {}
1123
1183
  }
@@ -1233,6 +1293,7 @@ export default function (pi: ExtensionAPI) {
1233
1293
  },
1234
1294
  invalidate: () => container.invalidate(),
1235
1295
  handleInput: (input: string) => usage.handleInput(input),
1296
+ handleMouse: (event: TuiMouseEvent) => usage.handleMouse({ ...event, y: event.y - 3 }),
1236
1297
  dispose: () => {},
1237
1298
  };
1238
1299
  });
package/package.json CHANGED
@@ -1,13 +1,26 @@
1
1
  {
2
2
  "name": "@po.dev/pi-usage-dashboard",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Local token and cost dashboard for pi sessions",
5
- "keywords": ["pi-package", "pi", "usage", "tokens", "dashboard"],
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "usage",
9
+ "tokens",
10
+ "dashboard"
11
+ ],
6
12
  "license": "MIT",
7
- "files": ["*.ts", "README.md"],
13
+ "files": [
14
+ "*.ts",
15
+ "README.md"
16
+ ],
8
17
  "peerDependencies": {
9
18
  "@earendil-works/pi-coding-agent": "*",
10
19
  "@earendil-works/pi-tui": "*"
11
20
  },
12
- "pi": { "extensions": ["./index.ts"] }
21
+ "pi": {
22
+ "extensions": [
23
+ "./index.ts"
24
+ ]
25
+ }
13
26
  }