@po.dev/pi-usage 1.0.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.
Files changed (2) hide show
  1. package/index.ts +289 -0
  2. package/package.json +15 -0
package/index.ts ADDED
@@ -0,0 +1,289 @@
1
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
5
+ import { type Theme, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+
8
+ const VERSION = "v1.0.0";
9
+ const usageDir = join(homedir(), ".pi", "agent", "usage");
10
+ const usageFile = join(usageDir, "events.jsonl");
11
+
12
+ // ── Data ──────────────────────────────────────────────────────────────────────
13
+
14
+ type ModelStats = { input: number; output: number; cost: number; count: number };
15
+ type Totals = { input: number; output: number; cost: number; count: number; models: Map<string, ModelStats> };
16
+
17
+ function emptyTotals(): Totals {
18
+ return { input: 0, output: 0, cost: 0, count: 0, models: new Map() };
19
+ }
20
+
21
+ function add(t: Totals, model: string, input: number, output: number, cost: number) {
22
+ t.input += input; t.output += output; t.cost += cost; t.count++;
23
+ const m = t.models.get(model) ?? { input: 0, output: 0, cost: 0, count: 0 };
24
+ m.input += input; m.output += output; m.cost += cost; m.count++;
25
+ t.models.set(model, m);
26
+ }
27
+
28
+ type Row = { timestamp: string; model: string; input: number; output: number; cost: number };
29
+
30
+ async function loadRows(): Promise<Row[]> {
31
+ try {
32
+ return (await readFile(usageFile, "utf8")).split("\n").filter(Boolean).map(l => JSON.parse(l) as Row);
33
+ } catch (e: unknown) {
34
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return [];
35
+ throw e;
36
+ }
37
+ }
38
+
39
+ function sumRows(rows: Row[], filter?: (r: Row) => boolean): Totals {
40
+ const t = emptyTotals();
41
+ for (const r of rows) if (!filter || filter(r)) add(t, r.model, r.input || 0, r.output || 0, r.cost || 0);
42
+ return t;
43
+ }
44
+
45
+ function sessionTotals(ctx: ExtensionCommandContext): Totals {
46
+ const t = emptyTotals();
47
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown";
48
+ for (const entry of ctx.sessionManager.getBranch()) {
49
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
50
+ const msg = entry.message as AssistantMessage;
51
+ add(t, model, msg.usage.input || 0, msg.usage.output || 0, msg.usage.cost?.total || 0);
52
+ }
53
+ return t;
54
+ }
55
+
56
+ // ── Format ────────────────────────────────────────────────────────────────────
57
+
58
+ function fmtTokens(n: number): string {
59
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
60
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
61
+ return String(n);
62
+ }
63
+
64
+ function fmtCost(n: number): string {
65
+ if (n >= 100) return `$${n.toFixed(0)}`;
66
+ if (n >= 1) return `$${n.toFixed(2)}`;
67
+ if (n >= 0.01) return `$${n.toFixed(3)}`;
68
+ return `$${n.toFixed(4)}`;
69
+ }
70
+
71
+ // ── Render helpers ────────────────────────────────────────────────────────────
72
+
73
+ // Pad ANSI string to target visible width
74
+ function padR(s: string, w: number): string {
75
+ const vw = visibleWidth(s);
76
+ return vw < w ? s + " ".repeat(w - vw) : s;
77
+ }
78
+
79
+ // Render a card box. Returns lines each exactly `cardW` visible chars wide.
80
+ function renderCard(title: string, content: string[], cardW: number, th: Theme): string[] {
81
+ const inner = cardW - 2;
82
+ const bdr = (s: string) => th.fg("border", s);
83
+ const titleThemed = th.fg("muted", title);
84
+ const titleVW = visibleWidth(titleThemed);
85
+ const dashAfter = Math.max(0, inner - 2 - titleVW - 1);
86
+
87
+ const lines: string[] = [];
88
+ lines.push(bdr("┌─ ") + titleThemed + bdr(" " + "─".repeat(dashAfter) + "┐"));
89
+ lines.push(bdr("│") + " ".repeat(inner) + bdr("│"));
90
+ for (const line of content) {
91
+ const cell = truncateToWidth(padR(" " + line, inner), inner);
92
+ lines.push(bdr("│") + cell + bdr("│"));
93
+ }
94
+ lines.push(bdr("│") + " ".repeat(inner) + bdr("│"));
95
+ lines.push(bdr("└") + bdr("─".repeat(inner)) + bdr("┘"));
96
+ return lines;
97
+ }
98
+
99
+ // Zip two card arrays side by side. Both must have equal length.
100
+ function sideBySide(left: string[], right: string[], gap = " "): string[] {
101
+ const lw = left[0] ? visibleWidth(left[0]) : 0;
102
+ const n = Math.max(left.length, right.length);
103
+ return Array.from({ length: n }, (_, i) => {
104
+ const l = i < left.length ? left[i]! : " ".repeat(lw);
105
+ const r = i < right.length ? right[i]! : "";
106
+ return l + gap + r;
107
+ });
108
+ }
109
+
110
+ // Normalize two content arrays to the same length (pad shorter with empty strings)
111
+ function equalLen(a: string[], b: string[]): [string[], string[]] {
112
+ const n = Math.max(a.length, b.length);
113
+ return [
114
+ [...a, ...Array<string>(n - a.length).fill("")],
115
+ [...b, ...Array<string>(n - b.length).fill("")],
116
+ ];
117
+ }
118
+
119
+ function progressBar(pct: number, barW: number, th: Theme): string {
120
+ const filled = Math.round(Math.max(0, Math.min(1, pct)) * barW);
121
+ return th.fg("accent", "█".repeat(filled)) + th.fg("dim", "░".repeat(Math.max(0, barW - filled)));
122
+ }
123
+
124
+ // ── Dashboard render ──────────────────────────────────────────────────────────
125
+
126
+ function renderDashboard(
127
+ th: Theme,
128
+ session: Totals,
129
+ today: Totals,
130
+ month: Totals,
131
+ currentModel: string,
132
+ width: number,
133
+ ): string[] {
134
+ const lines: string[] = [];
135
+ const indent = " ";
136
+
137
+ // Header
138
+ const nameStr = th.fg("accent", th.bold("pi-usage")) + " " + th.fg("dim", VERSION);
139
+ const hintStr = th.fg("dim", "/usage ");
140
+ const hdrGap = Math.max(0, width - visibleWidth(nameStr) - visibleWidth(hintStr) - indent.length);
141
+ lines.push(indent + nameStr + " ".repeat(hdrGap) + hintStr);
142
+ lines.push("");
143
+
144
+ // Model + progress bar
145
+ const shortModel = currentModel.split("/").pop() ?? currentModel;
146
+ const modelDisp = th.fg("accent", shortModel);
147
+ const todayPct = month.cost > 0 ? today.cost / month.cost : 0;
148
+ const fixedParts = indent.length + visibleWidth(modelDisp) + 4 + visibleWidth(fmtCost(today.cost)) + 2 + 4 + 4; // rough fixed
149
+ const barW = Math.max(8, width - fixedParts);
150
+ const bar = progressBar(todayPct, barW, th);
151
+ const pctStr = th.fg("dim", `${Math.round(todayPct * 100)}%`);
152
+ lines.push(indent + modelDisp + " " + th.fg("success", fmtCost(today.cost)) + " " + bar + " " + pctStr);
153
+ lines.push("");
154
+
155
+ // Cards — compute width so two fit side by side with indent and gap
156
+ const cardW = Math.floor((width - indent.length * 2 - 2) / 2); // 2 = gap chars
157
+
158
+ // Row 1: session | today
159
+ const sessContent = [
160
+ th.fg("warning", th.bold(fmtCost(session.cost))),
161
+ th.fg("dim", `${fmtTokens(session.input + session.output)} tokens · ${session.count} turns`),
162
+ ];
163
+ const todayContent = [
164
+ th.fg("accent", th.bold(fmtCost(today.cost))),
165
+ th.fg("dim", `${fmtTokens(today.input + today.output)} tokens · ${today.count} turns`),
166
+ ];
167
+ const [sc, tc] = equalLen(sessContent, todayContent);
168
+ const row1 = sideBySide(renderCard("This Session", sc, cardW, th), renderCard("Today", tc, cardW, th));
169
+ lines.push(...row1.map(l => indent + l));
170
+ lines.push("");
171
+
172
+ // Row 2: month | model breakdown
173
+ const monthContent = [
174
+ th.fg("success", th.bold(fmtCost(month.cost))),
175
+ th.fg("dim", `${fmtTokens(month.input + month.output)} tokens · ${month.count} turns`),
176
+ ];
177
+
178
+ const topModels = [...month.models.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 5);
179
+ const modelInner = cardW - 4; // card inner width minus "│ " and "│"
180
+ const modelContent: string[] = topModels.length > 0
181
+ ? topModels.map(([name, t]) => {
182
+ const short = name.split("/").pop()?.slice(0, 18) ?? name;
183
+ const nameDisp = th.fg("accent", short);
184
+ const costDisp = fmtCost(t.cost);
185
+ const gap = Math.max(1, modelInner - visibleWidth(nameDisp) - costDisp.length);
186
+ return nameDisp + " ".repeat(gap) + costDisp;
187
+ })
188
+ : [th.fg("muted", "no data yet")];
189
+
190
+ const [mc, mdc] = equalLen(monthContent, modelContent);
191
+ const row2 = sideBySide(renderCard("This Month", mc, cardW, th), renderCard("By Model", mdc, cardW, th));
192
+ lines.push(...row2.map(l => indent + l));
193
+ lines.push("");
194
+
195
+ // Footer
196
+ const footer = th.fg("dim", "Esc / Enter to close");
197
+ const footerPad = Math.max(0, width - visibleWidth(footer) - 2);
198
+ lines.push(" ".repeat(footerPad) + footer + " ");
199
+
200
+ return lines.map(l => truncateToWidth(l, width));
201
+ }
202
+
203
+ // ── Command ───────────────────────────────────────────────────────────────────
204
+
205
+ async function showUsage(ctx: ExtensionCommandContext, session: Totals) {
206
+ const rows = await loadRows();
207
+ const now = new Date();
208
+ const todayStr = now.toISOString().slice(0, 10);
209
+ const monthStr = now.toISOString().slice(0, 7);
210
+
211
+ const today = sumRows(rows, r => r.timestamp.startsWith(todayStr));
212
+ const month = sumRows(rows, r => r.timestamp.startsWith(monthStr));
213
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown";
214
+
215
+ if (ctx.mode !== "tui") {
216
+ const lines = [
217
+ `pi-usage ${VERSION}`,
218
+ `Session: ${fmtCost(session.cost)} (${fmtTokens(session.input + session.output)} tokens, ${session.count} turns)`,
219
+ `Today: ${fmtCost(today.cost)} (${fmtTokens(today.input + today.output)} tokens, ${today.count} turns)`,
220
+ `Month: ${fmtCost(month.cost)} (${fmtTokens(month.input + month.output)} tokens, ${month.count} turns)`,
221
+ `All-time: ${fmtCost(all.cost)}`,
222
+ ];
223
+ ctx.ui.notify(lines.join("\n"), "info");
224
+ return;
225
+ }
226
+
227
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
228
+ let cachedWidth: number | undefined;
229
+ let cachedLines: string[] | undefined;
230
+
231
+ return {
232
+ render(width: number): string[] {
233
+ if (cachedLines && cachedWidth === width) return cachedLines;
234
+ cachedLines = renderDashboard(theme, session, today, month, model, width);
235
+ cachedWidth = width;
236
+ return cachedLines;
237
+ },
238
+ invalidate() {
239
+ cachedWidth = undefined;
240
+ cachedLines = undefined;
241
+ },
242
+ handleInput(data: string) {
243
+ if (matchesKey(data, "escape") || matchesKey(data, "enter")) done(undefined);
244
+ },
245
+ };
246
+ });
247
+ }
248
+
249
+ // ── Extension ─────────────────────────────────────────────────────────────────
250
+
251
+ export default function (pi: ExtensionAPI) {
252
+ // Record every assistant message
253
+ pi.on("message_end", async (event, ctx) => {
254
+ if (event.message.role !== "assistant") return;
255
+ const msg = event.message as AssistantMessage;
256
+ if (!msg.usage) return;
257
+ await mkdir(usageDir, { recursive: true });
258
+ await appendFile(
259
+ usageFile,
260
+ JSON.stringify({
261
+ timestamp: new Date().toISOString(),
262
+ session: ctx.sessionManager.getSessionId(),
263
+ project: ctx.cwd,
264
+ model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unknown",
265
+ input: msg.usage.input || 0,
266
+ output: msg.usage.output || 0,
267
+ cost: msg.usage.cost?.total || 0,
268
+ }) + "\n",
269
+ );
270
+ });
271
+
272
+ pi.registerCommand("usage", {
273
+ description: "Show token/cost dashboard · /usage pin|unpin",
274
+ handler: async (args, ctx) => {
275
+ const sess = sessionTotals(ctx);
276
+
277
+ if (args.trim() === "pin") {
278
+ ctx.ui.setWidget("pi-usage", [`${ctx.model?.id ?? "?"}: ${fmtCost(sess.cost)} ${fmtTokens(sess.input + sess.output)} tokens`]);
279
+ return;
280
+ }
281
+ if (args.trim() === "unpin") {
282
+ ctx.ui.setWidget("pi-usage", undefined);
283
+ return;
284
+ }
285
+
286
+ await showUsage(ctx, sess);
287
+ },
288
+ });
289
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@po.dev/pi-usage",
3
+ "version": "1.0.0",
4
+ "description": "Rich token & cost usage dashboard for pi — /usage command with card-style TUI",
5
+ "keywords": ["pi-package"],
6
+ "license": "MIT",
7
+ "pi": {
8
+ "extensions": ["./index.ts"]
9
+ },
10
+ "peerDependencies": {
11
+ "@earendil-works/pi-ai": "*",
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "@earendil-works/pi-tui": "*"
14
+ }
15
+ }