decorated-pi 0.5.4 → 0.6.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/.codegraph/daemon.pid +6 -0
- package/AGENTS.md +92 -0
- package/README.md +53 -64
- package/commands/dp-model.ts +23 -0
- package/commands/dp-settings.ts +23 -0
- package/commands/mcp-status.ts +62 -0
- package/commands/retry.ts +19 -0
- package/commands/usage.ts +544 -0
- package/hooks/compaction.ts +204 -0
- package/hooks/externalize.ts +70 -0
- package/hooks/image-vision.ts +132 -0
- package/hooks/inject-agents-md.ts +164 -0
- package/hooks/mcp.ts +340 -0
- package/hooks/normalize-codeblocks.ts +88 -0
- package/hooks/pi-tool-filter.ts +28 -0
- package/{extensions/safety → hooks/redact}/types.ts +1 -8
- package/hooks/redact.ts +104 -0
- package/{extensions → hooks}/rtk.ts +92 -115
- package/hooks/session-title.ts +54 -0
- package/hooks/skeleton.ts +212 -0
- package/hooks/smart-at.ts +318 -0
- package/{extensions/file-times.ts → hooks/track-mtime.ts} +40 -38
- package/{extensions → hooks}/wakatime.ts +120 -122
- package/index.ts +155 -1
- package/package.json +5 -4
- package/{extensions/settings.ts → settings.ts} +32 -0
- package/{extensions → tools}/lsp/client.ts +0 -25
- package/{extensions → tools}/lsp/format.ts +2 -99
- package/{extensions → tools}/lsp/index.ts +1 -1
- package/{extensions → tools}/lsp/servers.ts +1 -1
- package/{extensions → tools}/lsp/tools.ts +1 -66
- package/{extensions → tools}/lsp/types.ts +0 -11
- package/tools/mcp/builtin/codegraph.ts +45 -0
- package/tools/mcp/builtin/context7.ts +10 -0
- package/tools/mcp/builtin/exa.ts +10 -0
- package/tools/mcp/builtin/index.ts +19 -0
- package/tools/mcp/cache.ts +124 -0
- package/{extensions → tools}/mcp/client.ts +2 -2
- package/{extensions/mcp/builtin.ts → tools/mcp/config.ts} +21 -181
- package/tools/mcp/index.ts +44 -0
- package/tools/mcp/tool-definition.ts +112 -0
- package/{extensions/patch.ts → tools/patch/core.ts} +336 -65
- package/{extensions/io.ts → tools/patch/index.ts} +29 -205
- package/tsconfig.json +10 -1
- package/ui/mcp-status.ts +202 -0
- package/ui/model-picker.ts +162 -0
- package/ui/module-settings.ts +83 -0
- package/ui/usage.ts +396 -0
- package/extensions/index.ts +0 -154
- package/extensions/io-tool-output.ts +0 -33
- package/extensions/mcp/index.ts +0 -435
- package/extensions/model-integration.ts +0 -531
- package/extensions/providers/ark-coding.ts +0 -75
- package/extensions/providers/index.ts +0 -9
- package/extensions/providers/ollama-cloud.ts +0 -101
- package/extensions/providers/qianfan-coding.ts +0 -71
- package/extensions/safety/index.ts +0 -102
- package/extensions/session-title.ts +0 -40
- package/extensions/slash.ts +0 -458
- package/extensions/smart-at.ts +0 -481
- package/extensions/subdir-agents.ts +0 -151
- /package/{extensions/safety → hooks/redact}/detect.ts +0 -0
- /package/{extensions/safety → hooks/redact}/entropy.ts +0 -0
- /package/{extensions/safety → hooks/redact}/patterns.ts +0 -0
- /package/{extensions → tools}/lsp/env.ts +0 -0
- /package/{extensions → tools}/lsp/manager.ts +0 -0
- /package/{extensions → tools}/lsp/prompt.ts +0 -0
- /package/{extensions → tools}/lsp/protocol.ts +0 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /usage — token usage statistics dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Incrementally syncs pi session JSONL files into a local index
|
|
5
|
+
* (~/.pi/agent/decorated-pi-usage.jsonl), then aggregates by time
|
|
6
|
+
* slice and model for interactive display.
|
|
7
|
+
*
|
|
8
|
+
* Zero agent-loop hooks — purely command-driven.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as fsPromises from "node:fs/promises";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { Container, matchesKey, Spacer, Text } from "@earendil-works/pi-tui";
|
|
18
|
+
|
|
19
|
+
import { loadUsageIndex, saveUsageIndex } from "../settings.js";
|
|
20
|
+
import { UsageReportComponent } from "../ui/usage.js";
|
|
21
|
+
|
|
22
|
+
// ─── Paths ─────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function agentDir(): string {
|
|
25
|
+
return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function usageFilePath(): string {
|
|
29
|
+
return path.join(agentDir(), "decorated-pi-usage.jsonl");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sessionsDir(): string {
|
|
33
|
+
return path.join(agentDir(), "sessions");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Types ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
interface UsageEntry {
|
|
39
|
+
ts: number;
|
|
40
|
+
model: string;
|
|
41
|
+
sessionFile: string;
|
|
42
|
+
input: number;
|
|
43
|
+
output: number;
|
|
44
|
+
cacheRead: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
cost: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Aggregate {
|
|
50
|
+
input: number;
|
|
51
|
+
output: number;
|
|
52
|
+
cacheRead: number;
|
|
53
|
+
cacheWrite: number;
|
|
54
|
+
cost: number;
|
|
55
|
+
turns: number;
|
|
56
|
+
hitRate: number; // 0-100
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UsageReport {
|
|
60
|
+
currentSession: Aggregate;
|
|
61
|
+
today: Aggregate;
|
|
62
|
+
thisWeek: Aggregate;
|
|
63
|
+
thisMonth: Aggregate;
|
|
64
|
+
allTime: Aggregate;
|
|
65
|
+
byModel: ModelSlice[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ModelSlice {
|
|
69
|
+
model: string;
|
|
70
|
+
currentSession: Aggregate;
|
|
71
|
+
today: Aggregate;
|
|
72
|
+
thisWeek: Aggregate;
|
|
73
|
+
thisMonth: Aggregate;
|
|
74
|
+
allTime: Aggregate;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Session file scanning ─────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function collectSessionFiles(
|
|
80
|
+
dir: string,
|
|
81
|
+
files: string[],
|
|
82
|
+
signal?: AbortSignal,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
try {
|
|
85
|
+
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (signal?.aborted) return;
|
|
88
|
+
const full = path.join(dir, entry.name);
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
await collectSessionFiles(full, files, signal);
|
|
91
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
92
|
+
files.push(full);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// skip unreadable dirs
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Ingestion ─────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async function ingestSessionFile(
|
|
103
|
+
filePath: string,
|
|
104
|
+
offset: number,
|
|
105
|
+
seenHashes: Set<string>,
|
|
106
|
+
signal?: AbortSignal,
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const fd = await fsPromises.open(filePath, "r");
|
|
109
|
+
try {
|
|
110
|
+
const stat = await fd.stat();
|
|
111
|
+
const remaining = stat.size - offset;
|
|
112
|
+
if (remaining <= 0) return;
|
|
113
|
+
|
|
114
|
+
const buf = Buffer.alloc(remaining);
|
|
115
|
+
await fd.read(buf, 0, remaining, offset);
|
|
116
|
+
const text = buf.toString("utf-8");
|
|
117
|
+
|
|
118
|
+
// When offset > 0, we may start at a line boundary (byte before
|
|
119
|
+
// offset is \n) or in the middle of a line. If mid-line, skip until
|
|
120
|
+
// after the next \n so we don't try to parse a half line. If at
|
|
121
|
+
// a boundary, start at position 0 so we don't drop the first
|
|
122
|
+
// complete line in the delta.
|
|
123
|
+
let textStart = 0;
|
|
124
|
+
if (offset > 0) {
|
|
125
|
+
const prevBuf = Buffer.alloc(1);
|
|
126
|
+
await fd.read(prevBuf, 0, 1, offset - 1);
|
|
127
|
+
if (prevBuf[0] !== 0x0a /* \n */) {
|
|
128
|
+
// mid-line: skip the residual
|
|
129
|
+
const nl = text.indexOf("\n");
|
|
130
|
+
if (nl === -1) return; // no complete line in the delta yet
|
|
131
|
+
textStart = nl + 1;
|
|
132
|
+
}
|
|
133
|
+
// else: at a line boundary, textStart stays 0
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const lines = text.slice(textStart).split("\n");
|
|
137
|
+
|
|
138
|
+
const entries: UsageEntry[] = [];
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
if (signal?.aborted) return;
|
|
142
|
+
if (i % 500 === 0) await new Promise<void>((r) => setImmediate(r));
|
|
143
|
+
|
|
144
|
+
const line = lines[i];
|
|
145
|
+
if (!line) continue;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const e = JSON.parse(line);
|
|
149
|
+
if (e.type !== "message" || e.message?.role !== "assistant") continue;
|
|
150
|
+
|
|
151
|
+
const msg = e.message;
|
|
152
|
+
const usage = msg.usage;
|
|
153
|
+
if (!usage) continue;
|
|
154
|
+
|
|
155
|
+
const ts =
|
|
156
|
+
typeof msg.timestamp === "number"
|
|
157
|
+
? msg.timestamp
|
|
158
|
+
: typeof e.timestamp === "string"
|
|
159
|
+
? new Date(e.timestamp).getTime()
|
|
160
|
+
: Date.now();
|
|
161
|
+
|
|
162
|
+
// dedup across forked/branched session files
|
|
163
|
+
const totalTokens =
|
|
164
|
+
(usage.input || 0) + (usage.output || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0);
|
|
165
|
+
const hash = `${ts}:${totalTokens}`;
|
|
166
|
+
if (seenHashes.has(hash)) continue;
|
|
167
|
+
seenHashes.add(hash);
|
|
168
|
+
|
|
169
|
+
const model = `${msg.provider ?? "unknown"}/${msg.model ?? "unknown"}`;
|
|
170
|
+
|
|
171
|
+
entries.push({
|
|
172
|
+
ts,
|
|
173
|
+
model,
|
|
174
|
+
sessionFile: filePath,
|
|
175
|
+
input: usage.input || 0,
|
|
176
|
+
output: usage.output || 0,
|
|
177
|
+
cacheRead: usage.cacheRead || 0,
|
|
178
|
+
cacheWrite: usage.cacheWrite || 0,
|
|
179
|
+
cost: usage.cost?.total || 0,
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
// skip malformed lines
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (entries.length > 0) {
|
|
187
|
+
const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
188
|
+
await fsPromises.appendFile(usageFilePath(), body);
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
await fd.close();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─── Incremental sync ──────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
export async function syncUsageIndex(signal?: AbortSignal): Promise<void> {
|
|
198
|
+
const index = loadUsageIndex();
|
|
199
|
+
const files: string[] = [];
|
|
200
|
+
const seenHashes = new Set<string>();
|
|
201
|
+
|
|
202
|
+
await collectSessionFiles(sessionsDir(), files, signal);
|
|
203
|
+
|
|
204
|
+
for (const filePath of files) {
|
|
205
|
+
if (signal?.aborted) return;
|
|
206
|
+
|
|
207
|
+
let stat: fs.Stats;
|
|
208
|
+
try {
|
|
209
|
+
stat = await fsPromises.stat(filePath);
|
|
210
|
+
} catch {
|
|
211
|
+
delete index[filePath];
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cached = index[filePath];
|
|
216
|
+
|
|
217
|
+
// Trigger a full reimport if any of:
|
|
218
|
+
// - no cache entry (first time seeing this file)
|
|
219
|
+
// - inode changed (file was replaced with a different inode)
|
|
220
|
+
// - inode was recycled BUT the file is shorter than we remembered
|
|
221
|
+
// (truncation/rotation)
|
|
222
|
+
if (
|
|
223
|
+
!cached ||
|
|
224
|
+
cached.inode !== stat.ino ||
|
|
225
|
+
stat.size < cached.size
|
|
226
|
+
) {
|
|
227
|
+
// full reimport
|
|
228
|
+
await ingestSessionFile(filePath, 0, seenHashes, signal);
|
|
229
|
+
} else if (stat.size > cached.size) {
|
|
230
|
+
// append-only → import delta
|
|
231
|
+
await ingestSessionFile(filePath, cached.size, seenHashes, signal);
|
|
232
|
+
}
|
|
233
|
+
// else: unchanged
|
|
234
|
+
|
|
235
|
+
index[filePath] = { inode: stat.ino, size: stat.size, mtime: stat.mtimeMs };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// prune stale entries
|
|
239
|
+
const existing = new Set(files);
|
|
240
|
+
for (const key of Object.keys(index)) {
|
|
241
|
+
if (!existing.has(key)) delete index[key];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
saveUsageIndex(index);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Full rebuild: clear index + JSONL, re-import everything. */
|
|
248
|
+
export async function rebuildUsageIndex(signal?: AbortSignal): Promise<void> {
|
|
249
|
+
saveUsageIndex({});
|
|
250
|
+
try {
|
|
251
|
+
await fsPromises.truncate(usageFilePath(), 0);
|
|
252
|
+
} catch {
|
|
253
|
+
// file may not exist yet
|
|
254
|
+
}
|
|
255
|
+
await syncUsageIndex(signal);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Read our JSONL ────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
export function readUsageEntries(): UsageEntry[] {
|
|
261
|
+
try {
|
|
262
|
+
if (!fs.existsSync(usageFilePath())) return [];
|
|
263
|
+
const text = fs.readFileSync(usageFilePath(), "utf-8");
|
|
264
|
+
const entries: UsageEntry[] = [];
|
|
265
|
+
for (const line of text.trim().split("\n")) {
|
|
266
|
+
if (!line) continue;
|
|
267
|
+
try {
|
|
268
|
+
entries.push(JSON.parse(line) as UsageEntry);
|
|
269
|
+
} catch {
|
|
270
|
+
// skip
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return entries;
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Aggregation ───────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function emptyAggregate(): Aggregate {
|
|
282
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, hitRate: 0 };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getPeriods(): { todayMs: number; weekStartMs: number; monthStartMs: number } {
|
|
286
|
+
const now = new Date();
|
|
287
|
+
const todayMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
288
|
+
|
|
289
|
+
const dow = now.getDay();
|
|
290
|
+
const monOffset = dow === 0 ? 6 : dow - 1; // Monday = 0
|
|
291
|
+
const weekStartMs = new Date(now.getFullYear(), now.getMonth(), now.getDate() - monOffset).getTime();
|
|
292
|
+
|
|
293
|
+
const monthStartMs = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
294
|
+
|
|
295
|
+
return { todayMs, weekStartMs, monthStartMs };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function accum(a: Aggregate, e: UsageEntry): void {
|
|
299
|
+
a.input += e.input;
|
|
300
|
+
a.output += e.output;
|
|
301
|
+
a.cacheRead += e.cacheRead;
|
|
302
|
+
a.cacheWrite += e.cacheWrite;
|
|
303
|
+
a.cost += e.cost;
|
|
304
|
+
a.turns++;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function computeHitRate(a: Aggregate): void {
|
|
308
|
+
const total = a.input + a.cacheRead + a.cacheWrite;
|
|
309
|
+
a.hitRate = total > 0 ? (100 * a.cacheRead) / total : 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function aggregate(entries: UsageEntry[], currentSessionFile?: string): UsageReport {
|
|
313
|
+
const { todayMs, weekStartMs, monthStartMs } = getPeriods();
|
|
314
|
+
|
|
315
|
+
type SliceKey = "currentSession" | "today" | "thisWeek" | "thisMonth" | "allTime";
|
|
316
|
+
|
|
317
|
+
const overall: Record<SliceKey, Aggregate> = {
|
|
318
|
+
currentSession: emptyAggregate(),
|
|
319
|
+
today: emptyAggregate(),
|
|
320
|
+
thisWeek: emptyAggregate(),
|
|
321
|
+
thisMonth: emptyAggregate(),
|
|
322
|
+
allTime: emptyAggregate(),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const modelMap = new Map<string, Record<SliceKey, Aggregate>>();
|
|
326
|
+
const modelLastUsed = new Map<string, number>();
|
|
327
|
+
const isCurrentSession = (e: UsageEntry) =>
|
|
328
|
+
currentSessionFile ? e.sessionFile === currentSessionFile : false;
|
|
329
|
+
|
|
330
|
+
for (const e of entries) {
|
|
331
|
+
accum(overall.allTime, e);
|
|
332
|
+
if (e.ts >= todayMs) accum(overall.today, e);
|
|
333
|
+
if (e.ts >= weekStartMs) accum(overall.thisWeek, e);
|
|
334
|
+
if (e.ts >= monthStartMs) accum(overall.thisMonth, e);
|
|
335
|
+
if (isCurrentSession(e)) accum(overall.currentSession, e);
|
|
336
|
+
|
|
337
|
+
let m = modelMap.get(e.model);
|
|
338
|
+
if (!m) {
|
|
339
|
+
m = {
|
|
340
|
+
currentSession: emptyAggregate(),
|
|
341
|
+
today: emptyAggregate(),
|
|
342
|
+
thisWeek: emptyAggregate(),
|
|
343
|
+
thisMonth: emptyAggregate(),
|
|
344
|
+
allTime: emptyAggregate(),
|
|
345
|
+
};
|
|
346
|
+
modelMap.set(e.model, m);
|
|
347
|
+
}
|
|
348
|
+
accum(m.allTime, e);
|
|
349
|
+
if (e.ts >= todayMs) accum(m.today, e);
|
|
350
|
+
if (e.ts >= weekStartMs) accum(m.thisWeek, e);
|
|
351
|
+
if (e.ts >= monthStartMs) accum(m.thisMonth, e);
|
|
352
|
+
if (isCurrentSession(e)) accum(m.currentSession, e);
|
|
353
|
+
|
|
354
|
+
// track most recent usage per model
|
|
355
|
+
if (e.ts > (modelLastUsed.get(e.model) ?? 0)) {
|
|
356
|
+
modelLastUsed.set(e.model, e.ts);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// compute hit rates
|
|
361
|
+
for (const key of Object.keys(overall) as SliceKey[]) {
|
|
362
|
+
computeHitRate(overall[key]);
|
|
363
|
+
}
|
|
364
|
+
for (const m of modelMap.values()) {
|
|
365
|
+
for (const key of Object.keys(m) as SliceKey[]) {
|
|
366
|
+
computeHitRate(m[key]);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const byModel: ModelSlice[] = Array.from(modelMap.entries())
|
|
371
|
+
.map(([model, slices]) => ({ model, ...slices }))
|
|
372
|
+
.sort(
|
|
373
|
+
(a, b) =>
|
|
374
|
+
(modelLastUsed.get(b.model) ?? 0) - (modelLastUsed.get(a.model) ?? 0) ||
|
|
375
|
+
a.model.localeCompare(b.model),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
return { ...overall, byModel };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ─── Formatting ────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
export function formatTokens(n: number): string {
|
|
384
|
+
if (n === 0) return "—";
|
|
385
|
+
if (n < 1000) return String(n);
|
|
386
|
+
if (n < 1_000_000) {
|
|
387
|
+
const k = n / 1000;
|
|
388
|
+
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
|
389
|
+
}
|
|
390
|
+
const m = n / 1_000_000;
|
|
391
|
+
return m >= 100 ? `${Math.round(m)}M` : `${m.toFixed(2)}M`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function formatCost(c: number): string {
|
|
395
|
+
if (c === 0) return "—";
|
|
396
|
+
if (c < 0.01) return "<$0.01";
|
|
397
|
+
if (c < 1000) return `$${c.toFixed(2)}`;
|
|
398
|
+
const k = c / 1000;
|
|
399
|
+
return `$${k.toFixed(1)}k`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function formatHitRate(hitRate: number, turns: number): string {
|
|
403
|
+
if (turns === 0) return "—";
|
|
404
|
+
return `${hitRate.toFixed(1)}%`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export type ColumnId = "input" | "output" | "cacheRead" | "cacheWrite" | "hitRate" | "cost";
|
|
408
|
+
|
|
409
|
+
const WIDE_COLS: ColumnId[] = ["input", "output", "cacheRead", "cacheWrite", "hitRate", "cost"];
|
|
410
|
+
const MED_COLS: ColumnId[] = ["input", "output", "hitRate", "cost"];
|
|
411
|
+
const NARROW_COLS: ColumnId[] = ["hitRate", "cost"];
|
|
412
|
+
|
|
413
|
+
export function pickColumns(width: number): ColumnId[] {
|
|
414
|
+
if (width >= 80) return WIDE_COLS;
|
|
415
|
+
if (width >= 50) return MED_COLS;
|
|
416
|
+
return NARROW_COLS;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function formatCell(col: ColumnId, agg: Aggregate): string {
|
|
420
|
+
switch (col) {
|
|
421
|
+
case "input":
|
|
422
|
+
return formatTokens(agg.input + agg.cacheRead + agg.cacheWrite);
|
|
423
|
+
case "output":
|
|
424
|
+
return formatTokens(agg.output);
|
|
425
|
+
case "cacheRead":
|
|
426
|
+
return formatTokens(agg.cacheRead);
|
|
427
|
+
case "cacheWrite":
|
|
428
|
+
return formatTokens(agg.cacheWrite);
|
|
429
|
+
case "hitRate":
|
|
430
|
+
return formatHitRate(agg.hitRate, agg.turns);
|
|
431
|
+
case "cost":
|
|
432
|
+
return formatCost(agg.cost);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Row label → columns → rendered line string */
|
|
437
|
+
export function formatRow(
|
|
438
|
+
label: string,
|
|
439
|
+
labelWidth: number,
|
|
440
|
+
agg: Aggregate,
|
|
441
|
+
cols: ColumnId[],
|
|
442
|
+
colWidths: Record<ColumnId, number>,
|
|
443
|
+
): string {
|
|
444
|
+
const pad = " ";
|
|
445
|
+
let line = label.padEnd(labelWidth);
|
|
446
|
+
for (const c of cols) {
|
|
447
|
+
const val = formatCell(c, agg);
|
|
448
|
+
line += pad + val.padStart(colWidths[c] ?? 0);
|
|
449
|
+
}
|
|
450
|
+
return line;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function pickModelDisplay(name: string, maxLen: number): string {
|
|
454
|
+
if (name.length <= maxLen) return name;
|
|
455
|
+
if (maxLen <= 3) return name.slice(0, maxLen);
|
|
456
|
+
return name.slice(0, maxLen - 1) + "…";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function formatDivider(cols: ColumnId[], colWidths: Record<ColumnId, number>, labelW: number): string {
|
|
460
|
+
const parts = ["─".repeat(labelW)];
|
|
461
|
+
for (const c of cols) {
|
|
462
|
+
parts.push("─".repeat((colWidths[c] ?? 0) + 2)); // +2 for padding
|
|
463
|
+
}
|
|
464
|
+
return parts.join("");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── Command registration ──────────────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
export function registerUsageCommand(pi: ExtensionAPI): void {
|
|
470
|
+
pi.registerCommand("usage", {
|
|
471
|
+
description: "Show token usage statistics (overall + per-model)",
|
|
472
|
+
handler: async (_args, ctx) => {
|
|
473
|
+
if (!ctx.hasUI) return;
|
|
474
|
+
|
|
475
|
+
const currentSessionFile = (ctx.sessionManager as any).getSessionFile?.() ??
|
|
476
|
+
(ctx.sessionManager as any).getSessionPath?.() ?? undefined;
|
|
477
|
+
|
|
478
|
+
// Phase 1: loading
|
|
479
|
+
const report = await ctx.ui.custom<UsageReport | null>((tui, theme, _kb, done) => {
|
|
480
|
+
const container = new Container();
|
|
481
|
+
const borderFn = (s: string) => theme.fg("border", s);
|
|
482
|
+
|
|
483
|
+
container.addChild(new DynamicBorder(borderFn));
|
|
484
|
+
container.addChild(new Spacer(1));
|
|
485
|
+
container.addChild(new Text(theme.fg("accent", " Usage Statistics"), 1, 0));
|
|
486
|
+
container.addChild(new Text(theme.fg("muted", " Syncing session data..."), 1, 0));
|
|
487
|
+
container.addChild(new Spacer(1));
|
|
488
|
+
container.addChild(new DynamicBorder(borderFn));
|
|
489
|
+
container.addChild(new Text(theme.fg("dim", " q close"), 1, 0));
|
|
490
|
+
|
|
491
|
+
let finished = false;
|
|
492
|
+
const controller = new AbortController();
|
|
493
|
+
|
|
494
|
+
syncUsageIndex(controller.signal)
|
|
495
|
+
.then(() => {
|
|
496
|
+
if (finished) return;
|
|
497
|
+
finished = true;
|
|
498
|
+
try {
|
|
499
|
+
const entries = readUsageEntries();
|
|
500
|
+
done(aggregate(entries, currentSessionFile));
|
|
501
|
+
} catch {
|
|
502
|
+
done(null);
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
.catch(() => {
|
|
506
|
+
if (!finished) {
|
|
507
|
+
finished = true;
|
|
508
|
+
done(null);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
render: (w: number) => container.render(w),
|
|
514
|
+
invalidate: () => container.invalidate(),
|
|
515
|
+
handleInput: (data: string) => {
|
|
516
|
+
if (matchesKey(data, "escape") || data === "q") {
|
|
517
|
+
if (!finished) {
|
|
518
|
+
finished = true;
|
|
519
|
+
controller.abort();
|
|
520
|
+
done(null);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if (!report) return;
|
|
528
|
+
|
|
529
|
+
// Phase 2: display
|
|
530
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
531
|
+
const rebuild = async () => {
|
|
532
|
+
try {
|
|
533
|
+
await rebuildUsageIndex();
|
|
534
|
+
const entries = readUsageEntries();
|
|
535
|
+
return aggregate(entries, currentSessionFile);
|
|
536
|
+
} catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
return new UsageReportComponent(theme, report, () => tui.requestRender?.(), () => done(), rebuild);
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
}
|