repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/usage/implementation.ts
Raw/**
* /usage — Usage statistics dashboard
*
* Aggregated usage across all sessions parsed from local JSONL files.
* Tab cycles: Today → This Week → Last Week → All Time.
* Toggle insights view with 'v' for cost optimization tips.
*
* Based on tmustier/pi-extensions (usage-extension).
* MIT License — https://github.com/tmustier/pi-extensions
*/
import { readdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type {
ExtensionCommandContext,
Theme,
} from "@earendil-works/pi-coding-agent";
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
import {
CancellableLoader,
Container,
matchesKey,
Spacer,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import { dbg } from "./src/debug.ts";
// ── Types ──────────────────────────────────────────────────────────────────
interface TokenStats {
total: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
interface BaseStats {
messages: number;
cost: number;
tokens: TokenStats;
}
interface ModelStats extends BaseStats {
sessions: Set<string>;
}
interface ProviderStats extends BaseStats {
sessions: Set<string>;
models: Map<string, ModelStats>;
}
interface TotalStats extends BaseStats {
sessions: number;
}
interface Insight {
percent: number;
headline: string;
advice: string;
}
interface PeriodInsights {
insights: Insight[];
}
interface RawMessage {
sessionId: string;
timestamp: number;
cost: number;
input: number;
cacheRead: number;
cacheWrite: number;
}
interface ParsedMessage {
provider: string;
model: string;
cost: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
timestamp: number;
}
// Claude subscription traffic is stamped with the klaus provider id in session
// files; surface it under its anthropic identity instead.
const PROVIDER_LABELS: Record<string, string> = {
klaus: "anthropic (klaus)",
};
interface ParsedSession {
sessionId: string;
messages: ParsedMessage[];
}
interface PeriodRawData {
messages: RawMessage[];
sessionCosts: Map<string, number>;
}
interface GlobalSessionSpan {
startMs: number;
endMs: number;
}
interface TimeFilteredStats {
providers: Map<string, ProviderStats>;
totals: TotalStats;
insights: PeriodInsights;
}
interface UsageData {
today: TimeFilteredStats;
thisWeek: TimeFilteredStats;
lastWeek: TimeFilteredStats;
allTime: TimeFilteredStats;
}
type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
type ViewMode = "table" | "insights";
// ── Column config ──────────────────────────────────────────────────────────
interface DataColumn {
label: string;
width: number;
dimmed?: boolean;
getValue: (stats: BaseStats & { sessions: Set<string> | number }) => string;
}
const SESSIONS_COL: DataColumn = {
label: "Sessions",
width: 9,
getValue: (s) =>
formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
};
const MSGS_COL: DataColumn = {
label: "Msgs",
width: 9,
getValue: (s) => formatNumber(s.messages),
};
const COST_COL: DataColumn = {
label: "Cost",
width: 9,
getValue: (s) => formatCost(s.cost),
};
const TOKENS_COL: DataColumn = {
label: "Tokens",
width: 9,
getValue: (s) => formatTokens(s.tokens.total),
};
const INPUT_COL: DataColumn = {
label: "↑In",
width: 8,
dimmed: true,
getValue: (s) => formatTokens(s.tokens.input + s.tokens.cacheWrite),
};
const OUTPUT_COL: DataColumn = {
label: "↓Out",
width: 8,
dimmed: true,
getValue: (s) => formatTokens(s.tokens.output),
};
const CACHE_COL: DataColumn = {
label: "Cache",
width: 8,
dimmed: true,
getValue: (s) => formatTokens(s.tokens.cacheRead + s.tokens.cacheWrite),
};
const FULL_COLS: DataColumn[] = [
SESSIONS_COL,
MSGS_COL,
COST_COL,
TOKENS_COL,
INPUT_COL,
OUTPUT_COL,
CACHE_COL,
];
interface TableLayout {
columns: DataColumn[];
nameWidth: number;
tableWidth: number;
compact: boolean;
}
const MAX_NAME_WIDTH = 26;
function getTableLayout(width: number): TableLayout {
const layouts: Array<{
columns: DataColumn[];
minName: number;
compact?: boolean;
}> = [
{ columns: FULL_COLS, minName: MAX_NAME_WIDTH },
{
columns: [SESSIONS_COL, MSGS_COL, COST_COL, TOKENS_COL],
minName: 14,
compact: true,
},
{
columns: [SESSIONS_COL, COST_COL, TOKENS_COL],
minName: 12,
compact: true,
},
{ columns: [COST_COL, TOKENS_COL], minName: 10, compact: true },
{ columns: [COST_COL], minName: 8, compact: true },
];
for (const c of layouts) {
const colsWidth = c.columns.reduce((s, col) => s + col.width, 0);
const nameWidth = Math.min(MAX_NAME_WIDTH, Math.max(width - colsWidth, 0));
if (nameWidth >= c.minName)
return {
columns: c.columns,
nameWidth,
tableWidth: nameWidth + colsWidth,
compact: !!c.compact,
};
}
const fallback = layouts.at(-1);
if (!fallback) throw new Error("usage layouts must not be empty");
const fw = fallback.columns.reduce((s, col) => s + col.width, 0);
return {
columns: fallback.columns,
nameWidth: Math.min(MAX_NAME_WIDTH, Math.max(width - fw, 0)),
tableWidth: 0,
compact: true,
};
}
// ── Data collection ────────────────────────────────────────────────────────
async function collectSessionFiles(
dir: string,
files: string[],
signal?: AbortSignal,
): Promise<void> {
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (signal?.aborted) return;
const p = join(dir, entry.name);
if (entry.isDirectory()) await collectSessionFiles(p, files, signal);
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(p);
}
} catch {
/* skip */
}
}
const YIELD_EVERY_N_LINES = 500;
async function parseSessionFile(
filePath: string,
seenHashes: Set<string>,
signal?: AbortSignal,
): Promise<ParsedSession | null> {
try {
const content = await readFile(filePath, "utf8");
if (signal?.aborted) return null;
const lines = content.trim().split("\n");
const messages: ParsedMessage[] = [];
let sessionId = "";
for (let i = 0; i < lines.length; i++) {
if (signal?.aborted) return null;
if (i % YIELD_EVERY_N_LINES === 0)
await new Promise<void>((r) => setImmediate(r));
const line = lines[i];
if (!line?.trim()) continue;
const entry = parseJsonLine(line);
if (!entry) continue;
if (entry.type === "session") {
sessionId = entry.id as string;
} else if (
entry.type === "message" &&
(entry.message as any)?.role === "assistant"
) {
const msg = extractUsageMessage(
entry.message as Record<string, unknown>,
);
if (!msg) continue;
const hash = `${msg.timestamp}:${msg.input + msg.output + msg.cacheRead + msg.cacheWrite}`;
if (seenHashes.has(hash)) continue;
seenHashes.add(hash);
messages.push(msg);
}
}
return sessionId ? { sessionId, messages } : null;
} catch {
return null;
}
}
function parseJsonLine(line: string): Record<string, unknown> | null {
try {
return JSON.parse(line);
} catch {
return null;
}
}
function extractUsageMessage(
msg: Record<string, unknown>,
): ParsedMessage | null {
const usage = msg.usage as Record<string, number | undefined> | undefined;
if (!usage || !msg.provider || !msg.model) return null;
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const cacheRead = usage.cacheRead ?? 0;
const cacheWrite = usage.cacheWrite ?? 0;
const cost = (usage.cost as Record<string, number> | undefined)?.total ?? 0;
const fallbackTs = msg.timestamp
? new Date(msg.timestamp as string).getTime()
: 0;
const timestamp =
(msg.timestamp as number) || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
return {
provider:
PROVIDER_LABELS[msg.provider as string] ?? (msg.provider as string),
model: msg.model as string,
cost,
input,
output,
cacheRead,
cacheWrite,
timestamp,
};
}
function accumulateStats(
target: BaseStats & { sessions: Set<string> | number },
sessionId: string,
cost: number,
tokens: TokenStats,
): void {
if (target.sessions instanceof Set) target.sessions.add(sessionId);
target.messages++;
target.cost += cost;
target.tokens.total += tokens.total;
target.tokens.input += tokens.input;
target.tokens.output += tokens.output;
target.tokens.cacheRead += tokens.cacheRead;
target.tokens.cacheWrite += tokens.cacheWrite;
}
function emptyTokens(): TokenStats {
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
}
function emptyTimeFiltered(): TimeFilteredStats {
return {
providers: new Map(),
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
insights: { insights: [] },
};
}
function emptyUsage(): UsageData {
return {
today: emptyTimeFiltered(),
thisWeek: emptyTimeFiltered(),
lastWeek: emptyTimeFiltered(),
allTime: emptyTimeFiltered(),
};
}
const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
function getPeriods(
ts: number,
todayMs: number,
weekMs: number,
lastWeekMs: number,
): TabName[] {
const periods: TabName[] = ["allTime"];
if (ts >= todayMs) periods.push("today");
if (ts >= weekMs) periods.push("thisWeek");
else if (ts >= lastWeekMs) periods.push("lastWeek");
return periods;
}
async function collectUsageData(
sessionDir: string,
signal?: AbortSignal,
): Promise<UsageData | null> {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
const startOfWeek = new Date();
const dow = startOfWeek.getDay();
startOfWeek.setDate(startOfWeek.getDate() - (dow === 0 ? 6 : dow - 1));
startOfWeek.setHours(0, 0, 0, 0);
const weekMs = startOfWeek.getTime();
const startOfLastWeek = new Date(startOfWeek);
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
const lastWeekMs = startOfLastWeek.getTime();
const data = emptyUsage();
const rawByPeriod: Record<TabName, PeriodRawData> = {
today: { messages: [], sessionCosts: new Map() },
thisWeek: { messages: [], sessionCosts: new Map() },
lastWeek: { messages: [], sessionCosts: new Map() },
allTime: { messages: [], sessionCosts: new Map() },
};
const globalSessionSpans = new Map<string, GlobalSessionSpan>();
const files: string[] = [];
// sessionDir is the per-project dir (<root>/<encoded-cwd>/).
// Go up one level to get the root sessions dir so we scan ALL projects.
await collectSessionFiles(dirname(sessionDir), files, signal);
if (signal?.aborted) return null;
const seenHashes = new Set<string>();
for (const filePath of files) {
if (signal?.aborted) return null;
const parsed = await parseSessionFile(filePath, seenHashes, signal);
if (!parsed) continue;
for (const msg of parsed.messages) {
if (msg.timestamp > 0) {
const span = globalSessionSpans.get(parsed.sessionId);
if (!span)
globalSessionSpans.set(parsed.sessionId, {
startMs: msg.timestamp,
endMs: msg.timestamp,
});
else {
if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
}
}
const periods = getPeriods(msg.timestamp, todayMs, weekMs, lastWeekMs);
const tokens = {
total: msg.input + msg.output + msg.cacheWrite,
input: msg.input,
output: msg.output,
cacheRead: msg.cacheRead,
cacheWrite: msg.cacheWrite,
};
for (const period of periods) {
const stats = data[period];
let providerStats = stats.providers.get(msg.provider);
if (!providerStats) {
providerStats = {
sessions: new Set(),
messages: 0,
cost: 0,
tokens: emptyTokens(),
models: new Map(),
};
stats.providers.set(msg.provider, providerStats);
}
let modelStats = providerStats.models.get(msg.model);
if (!modelStats) {
modelStats = {
sessions: new Set(),
messages: 0,
cost: 0,
tokens: emptyTokens(),
};
providerStats.models.set(msg.model, modelStats);
}
modelStats.sessions.add(parsed.sessionId);
accumulateStats(modelStats, parsed.sessionId, msg.cost, tokens);
accumulateStats(providerStats, parsed.sessionId, msg.cost, tokens);
accumulateStats(stats.totals, parsed.sessionId, msg.cost, tokens);
rawByPeriod[period].messages.push({
sessionId: parsed.sessionId,
timestamp: msg.timestamp,
cost: msg.cost,
input: msg.input,
cacheRead: msg.cacheRead,
cacheWrite: msg.cacheWrite,
});
rawByPeriod[period].sessionCosts.set(
parsed.sessionId,
(rawByPeriod[period].sessionCosts.get(parsed.sessionId) ?? 0) +
msg.cost,
);
}
}
await new Promise<void>((r) => setImmediate(r));
}
// Compute insights
const longSessionIds = new Set<string>();
for (const [id, span] of globalSessionSpans) {
if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
}
for (const period of TAB_ORDER)
data[period].insights = computeInsights(
rawByPeriod[period],
longSessionIds,
);
return data;
}
// ── Insights ───────────────────────────────────────────────────────────────
const PARALLEL_WINDOW_MS = 2 * 60_000;
const PARALLEL_THRESHOLD = 4;
const LARGE_CONTEXT = 150_000;
const LARGE_UNCACHED = 100_000;
const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
const TOP_N = 5;
const MIN_MSG_PARALLEL = 10;
function computeInsights(
raw: PeriodRawData,
longSessionIds: Set<string>,
): PeriodInsights {
if (!raw.messages.length) return { insights: [] };
const total = raw.messages.reduce((s, m) => s + m.cost, 0);
if (total <= 0) return { insights: [] };
const candidates: Insight[] = [];
// Parallel
const timed = raw.messages.filter((m) => m.timestamp > 0);
if (
timed.length >= MIN_MSG_PARALLEL &&
new Set(timed.map((m) => m.sessionId)).size >= PARALLEL_THRESHOLD
) {
const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
const sidCount = new Map<string, number>();
let unique = 0,
left = 0,
right = 0,
parallelCost = 0;
for (const cur of sorted) {
while (
right < sorted.length &&
sorted[right]?.timestamp <= cur.timestamp + PARALLEL_WINDOW_MS
) {
const sid = sorted[right]?.sessionId;
const n = (sidCount.get(sid) ?? 0) + 1;
sidCount.set(sid, n);
if (n === 1) unique++;
right++;
}
while (
left < right &&
sorted[left]?.timestamp < cur.timestamp - PARALLEL_WINDOW_MS
) {
const sid = sorted[left]?.sessionId;
const n = (sidCount.get(sid) ?? 0) - 1;
if (!n) {
sidCount.delete(sid);
unique--;
} else sidCount.set(sid, n);
left++;
}
if (unique >= PARALLEL_THRESHOLD) parallelCost += cur.cost;
}
if (parallelCost > 0)
candidates.push({
percent: (parallelCost / total) * 100,
headline: `of cost was while ${PARALLEL_THRESHOLD}+ sessions ran in parallel`,
advice: "Queueing sessions uses capacity more evenly.",
});
}
// Large context
const largeCtxW = raw.messages
.filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT)
.reduce((s, m) => s + m.cost, 0);
if (largeCtxW > 0)
candidates.push({
percent: (largeCtxW / total) * 100,
headline: `of cost was at >${Math.round(LARGE_CONTEXT / 1000)}k context`,
advice: "/compact mid-task, /clear when switching tasks.",
});
// Uncached
const uncachedW = raw.messages
.filter((m) => m.input + m.cacheWrite > LARGE_UNCACHED)
.reduce((s, m) => s + m.cost, 0);
if (uncachedW > 0)
candidates.push({
percent: (uncachedW / total) * 100,
headline: `of cost came from >${Math.round(LARGE_UNCACHED / 1000)}k-token uncached prompts`,
advice: "/compact before stepping away keeps the cold-start small.",
});
// Long sessions
const longW = raw.messages
.filter((m) => longSessionIds.has(m.sessionId))
.reduce((s, m) => s + m.cost, 0);
if (longW > 0)
candidates.push({
percent: (longW / total) * 100,
headline: `of cost came from ${Math.round(LONG_SESSION_MS / 3600_000)}h+ sessions`,
advice: "Continuous usage can add up quickly.",
});
// Top-N
if (raw.sessionCosts.size > TOP_N) {
const sorted = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
const topW = sorted.slice(0, TOP_N).reduce((s, c) => s + c, 0);
candidates.push({
percent: (topW / total) * 100,
headline: `of cost from top ${TOP_N} sessions`,
advice: "A small number of sessions drives most spend.",
});
}
return {
insights: candidates
.filter((i) => i.percent >= 1)
.sort((a, b) => b.percent - a.percent),
};
}
// ── Formatting ─────────────────────────────────────────────────────────────
function formatCost(c: number): string {
if (c === 0) return "-";
if (c < 0.01) return `$${c.toFixed(4)}`;
if (c < 10) return `$${c.toFixed(2)}`;
if (c < 100) return `$${c.toFixed(1)}`;
return `$${Math.round(c)}`;
}
function formatTokens(n: number): string {
if (n === 0) return "-";
if (n < 1_000) return n.toString();
if (n < 10_000) return `${(n / 1_000).toFixed(1)}k`;
if (n < 1_000_000) return `${Math.round(n / 1_000)}k`;
if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
return `${Math.round(n / 1_000_000)}M`;
}
function formatNumber(n: number): string {
return n === 0 ? "-" : n.toLocaleString();
}
function padLeft(s: string, len: number): string {
const v = visibleWidth(s);
return v >= len ? s : " ".repeat(len - v) + s;
}
function padRight(s: string, len: number): string {
const v = visibleWidth(s);
return v >= len ? s : s + " ".repeat(len - v);
}
function fitCell(
s: string,
len: number,
align: "left" | "right" = "left",
): string {
return align === "right"
? padLeft(truncateToWidth(s, len), len)
: padRight(truncateToWidth(s, len), len);
}
function pickFitting(w: number, variants: string[]): string {
for (const v of variants) if (visibleWidth(v) <= w) return v;
return variants[variants.length - 1] || "";
}
function formatPct(p: number): string {
return p >= 10 ? `${Math.round(p)}%` : `${Math.round(p * 10) / 10}%`;
}
// ── Component ──────────────────────────────────────────────────────────────
const TAB_LABELS: Record<TabName, string> = {
today: "Today",
thisWeek: "This Week",
lastWeek: "Last Week",
allTime: "All Time",
};
class UsageComponent {
private activeTab: TabName = "allTime";
private viewMode: ViewMode = "table";
private data: UsageData;
private selectedIndex = 0;
private expanded = new Set<string>();
private providerOrder: string[] = [];
private theme: Theme;
private requestRender: () => void;
private done: () => void;
constructor(
theme: Theme,
data: UsageData,
requestRender: () => void,
done: () => void,
) {
this.theme = theme;
this.data = data;
this.requestRender = requestRender;
this.done = done;
this.updateProviderOrder();
}
private updateProviderOrder() {
const stats = this.data[this.activeTab];
this.providerOrder = Array.from(stats.providers.entries())
.sort((a, b) => b[1].cost - a[1].cost)
.map(([n]) => n);
this.selectedIndex = Math.min(
this.selectedIndex,
Math.max(0, this.providerOrder.length - 1),
);
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
this.done();
return;
}
if (matchesKey(data, "v")) {
this.viewMode = this.viewMode === "table" ? "insights" : "table";
this.requestRender();
return;
}
if (matchesKey(data, "tab") || matchesKey(data, "right")) {
const idx = TAB_ORDER.indexOf(this.activeTab);
this.activeTab =
TAB_ORDER[(idx + 1) % TAB_ORDER.length] ?? this.activeTab;
this.updateProviderOrder();
this.requestRender();
} else if (matchesKey(data, "shift+tab") || matchesKey(data, "left")) {
const idx = TAB_ORDER.indexOf(this.activeTab);
this.activeTab =
TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length] ??
this.activeTab;
this.updateProviderOrder();
this.requestRender();
} else if (this.viewMode === "table" && matchesKey(data, "up")) {
if (this.selectedIndex > 0) {
this.selectedIndex--;
this.requestRender();
}
} else if (this.viewMode === "table" && matchesKey(data, "down")) {
if (this.selectedIndex < this.providerOrder.length - 1) {
this.selectedIndex++;
this.requestRender();
}
} else if (
this.viewMode === "table" &&
(matchesKey(data, "enter") || matchesKey(data, "space"))
) {
const p = this.providerOrder[this.selectedIndex];
if (p) {
this.expanded.has(p) ? this.expanded.delete(p) : this.expanded.add(p);
this.requestRender();
}
}
}
render(width: number): string[] {
return this.viewMode === "insights"
? this.renderInsightsView(width)
: this.renderTableView(width);
}
private renderInsightsView(width: number): string[] {
const th = this.theme;
const stats = this.data[this.activeTab];
const lines: string[] = [];
lines.push(th.fg("accent", th.bold("Usage Insights")), "");
lines.push(
th.fg(
"dim",
`What's contributing to your cost? ${TAB_LABELS[this.activeTab]} · weighted by USD`,
),
"",
);
if (!stats.totals.messages) {
lines.push(th.fg("dim", " No usage for this period."), "");
} else if (!stats.totals.cost) {
lines.push(th.fg("dim", " No cost data."), "");
} else if (!stats.insights.insights.length) {
lines.push(th.fg("dim", " No insights above 1%."), "");
} else {
for (const insight of stats.insights.insights) {
lines.push(
`${th.fg("accent", th.bold(formatPct(insight.percent)))} ${insight.headline}`,
);
for (const w of wrapTextWithAnsi(
insight.advice,
Math.max(width - 5, 30),
))
lines.push(` ${th.fg("dim", w)}`);
lines.push("");
}
}
lines.push(
th.fg(
"dim",
pickFitting(width, [
"[Tab/←→] period [v] table [q] close",
"[q] close",
]),
),
);
return lines.map((l) => truncateToWidth(l, width));
}
private renderTableView(width: number): string[] {
const th = this.theme;
const stats = this.data[this.activeTab];
const layout = getTableLayout(width);
const lines: string[] = [];
// Tabs
const tabs = TAB_ORDER.map((t) =>
t === this.activeTab
? th.fg("accent", `[${TAB_LABELS[t]}]`)
: th.fg("dim", ` ${TAB_LABELS[t]} `),
).join(" ");
lines.push(
pickFitting(width, [
tabs,
th.fg("accent", `[${TAB_LABELS[this.activeTab]}]`),
]),
);
if (layout.compact)
lines.push(
...wrapTextWithAnsi(
th.fg("dim", "Compact view. Widen terminal for more columns."),
Math.max(width, 1),
),
);
lines.push("");
// Header
let headerLine = fitCell("Provider / Model", layout.nameWidth);
for (const col of layout.columns)
headerLine += col.dimmed
? th.fg("dim", fitCell(col.label, col.width, "right"))
: fitCell(col.label, col.width, "right");
lines.push(th.fg("muted", headerLine));
lines.push(th.fg("border", "─".repeat(layout.tableWidth)));
// Rows
if (!this.providerOrder.length) {
lines.push(th.fg("dim", " No usage data"));
}
for (let i = 0; i < this.providerOrder.length; i++) {
const name = this.providerOrder[i];
if (!name) continue;
const prov = stats.providers.get(name);
if (!prov) continue;
const sel = i === this.selectedIndex;
const exp = this.expanded.has(name);
const arrow = sel
? th.fg("accent", `${exp ? "▾" : "▸"} `)
: th.fg("dim", `${exp ? "▾" : "▸"} `);
lines.push(
this.row(name, prov, layout, { prefix: arrow, selected: sel }),
);
if (exp) {
for (const [mn, ms] of Array.from(prov.models.entries()).sort(
(a, b) => b[1].cost - a[1].cost,
)) {
lines.push(this.row(mn, ms, layout, { indent: 4, dimAll: true }));
}
}
}
// Totals
lines.push(th.fg("border", "─".repeat(layout.tableWidth)));
let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
for (const col of layout.columns)
totalRow += col.dimmed
? th.fg("dim", fitCell(col.getValue(stats.totals), col.width, "right"))
: fitCell(col.getValue(stats.totals), col.width, "right");
lines.push(totalRow);
lines.push("");
lines.push(
th.fg(
"dim",
pickFitting(width, [
"Tokens = In + Out + CacheWrite · ↑In = In + CacheWrite (v0.2.0+)",
"Incl. CacheWrite (v0.2.0+)",
]),
),
);
lines.push(
th.fg(
"dim",
pickFitting(width, [
"[Tab/←→] period [↑↓] select [Enter] expand [v] insights [q] close",
"[↑↓] select [v] insights [q] close",
"[q] close",
]),
),
);
return lines.map((l) => truncateToWidth(l, width));
}
private row(
name: string,
stats: BaseStats & { sessions: Set<string> | number },
layout: TableLayout,
opts: {
prefix?: string;
indent?: number;
selected?: boolean;
dimAll?: boolean;
} = {},
): string {
const th = this.theme;
const { indent = 0, selected = false, dimAll = false } = opts;
const rawPrefix = opts.prefix ?? " ".repeat(indent);
const prefixW = visibleWidth(truncateToWidth(rawPrefix, layout.nameWidth));
const innerW = Math.max(layout.nameWidth - prefixW, 0);
const truncName = innerW > 0 ? truncateToWidth(name, innerW) : "";
const styledName = selected
? th.fg("accent", truncName)
: dimAll
? th.fg("dim", truncName)
: truncName;
let row = rawPrefix + (innerW > 0 ? padRight(styledName, innerW) : "");
for (const col of layout.columns) {
const v = fitCell(col.getValue(stats), col.width, "right");
row += col.dimmed || dimAll ? th.fg("dim", v) : v;
}
return row;
}
invalidate(): void {}
dispose(): void {}
}
export async function showUsage(
_args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
if (!ctx.hasUI) {
dbg?.("command.skipped");
return;
}
let loadFailed = false;
const data = await ctx.ui.custom<UsageData | null>(
(tui, theme, _kb, done) => {
const loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
"Loading Usage...",
);
let finished = false;
const finish = (v: UsageData | null) => {
if (finished) return;
finished = true;
loader.dispose();
done(v);
};
loader.onAbort = () => finish(null);
collectUsageData(ctx.sessionManager.getSessionDir(), loader.signal)
.then(finish)
.catch(() => {
loadFailed = true;
dbg?.("data.load.error");
finish(null);
});
return loader;
},
);
if (!data) {
if (!loadFailed) dbg?.("data.load.cancelled");
return;
}
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new Spacer(1));
container.addChild(new DynamicBorder((s: string) => theme.fg("border", s)));
container.addChild(new Spacer(1));
const usage = new UsageComponent(
theme,
data,
() => tui.requestRender(),
() => done(),
);
return {
render: (w: number) => {
const border = container.render(w).map((l) => truncateToWidth(l, w));
return [
...border,
...usage.render(w),
"",
theme.fg("border", "─".repeat(w)),
].map((l) => truncateToWidth(l, w));
},
invalidate: () => container.invalidate(),
handleInput: (input: string) => usage.handleInput(input),
dispose: () => {},
};
});
}