@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.
- package/index.ts +289 -0
- 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
|
+
}
|