Luigit
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: () => {},
		};
	});
}