/** * /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; } interface ProviderStats extends BaseStats { sessions: Set; models: Map; } 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 = { klaus: "anthropic (klaus)", }; interface ParsedSession { sessionId: string; messages: ParsedMessage[]; } interface PeriodRawData { messages: RawMessage[]; sessionCosts: Map; } interface GlobalSessionSpan { startMs: number; endMs: number; } interface TimeFilteredStats { providers: Map; 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 | 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 { 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, signal?: AbortSignal, ): Promise { 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((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, ); 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 | null { try { return JSON.parse(line); } catch { return null; } } function extractUsageMessage( msg: Record, ): ParsedMessage | null { const usage = msg.usage as Record | 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 | 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 | 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 { 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 = { today: { messages: [], sessionCosts: new Map() }, thisWeek: { messages: [], sessionCosts: new Map() }, lastWeek: { messages: [], sessionCosts: new Map() }, allTime: { messages: [], sessionCosts: new Map() }, }; const globalSessionSpans = new Map(); const files: string[] = []; // sessionDir is the per-project dir (//). // 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(); 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((r) => setImmediate(r)); } // Compute insights const longSessionIds = new Set(); 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, ): 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(); 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 = { 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(); 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 | 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 { if (!ctx.hasUI) { dbg?.("command.skipped"); return; } let loadFailed = false; const data = await ctx.ui.custom( (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((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: () => {}, }; }); }