/** * pi-burn core logic — no pi dependencies, fully testable standalone. */ export type RequestRecord = { endTime: number; // ms epoch cost: number; // USD total inputTokens: number; // non-cached input tokens for the run outputTokens: number; // total output tokens for the run cacheWriteTokens: number; cacheHitTokens: number; // Per-type cost breakdown; all four sum to `cost`. inputCost?: number; outputCost?: number; cacheReadCost?: number; cacheWriteCost?: number; }; const ANSI_RESET = "\x1b[0m"; // ── Graph ───────────────────────────────────────────────────────────────────── // // Returns one row per cost type (cr, in, cw, out), each a labeled sparkline // of that cost across round trips. Rows are omitted when all values are zero. // // All four rows share the same scale (based on the peak total cost across the // entire session history) so bars remain stable as the window scrolls. // // Pass a partial `liveRecord` for the current in-flight request; its bar // appears dim at the right edge while the request is still streaming. // // Block characters give 9 height levels (space + ▁▂▃▄▅▆▇█). // cr=cyan in=green cw=amber out=orange-red type CostColors = { cacheRead: string; input: string; cacheWrite: string; output: string; }; const COST_COLORS: CostColors = { cacheRead: "\x1b[38;2;0;180;200m", input: "\x1b[38;2;80;190;80m", cacheWrite: "\x1b[38;2;210;170;0m", output: "\x1b[38;2;220;80;0m", }; // Four evenly-spaced grays so cost types remain visually distinct. const COST_COLORS_GRAY: CostColors = { cacheRead: "\x1b[38;2;120;120;120m", input: "\x1b[38;2;150;150;150m", cacheWrite: "\x1b[38;2;180;180;180m", output: "\x1b[38;2;210;210;210m", }; const DIM = "\x1b[2m"; // Braille dot layout (U+2800 base, one bit per dot): // // left right // dot7 dot8 bit 6 (64), bit 7 (128) ← top row // dot3 dot6 bit 2 (4), bit 5 (32) // dot2 dot5 bit 1 (2), bit 4 (16) // dot1 dot4 bit 0 (1), bit 3 (8) ← bottom row // // Filling from bottom up: // left column: dot7(64), dot3(4), dot2(2), dot1(1) // right column: dot8(128), dot6(32), dot5(16), dot4(8) const COL_LEFT_BITS = [64, 4, 2, 1] as const; const COL_RIGHT_BITS = [128, 32, 16, 8] as const; function brailleChar(leftHeight: number, rightHeight: number): string { let bits = 0; for (let i = 0; i < leftHeight; i++) bits |= COL_LEFT_BITS[i]!; for (let i = 0; i < rightHeight; i++) bits |= COL_RIGHT_BITS[i]!; return String.fromCodePoint(0x2800 + bits); } // Stacking order bottom→top: cr in cw out // Returns the color for the cost type occupying `dotMid` in record `r`. function costZoneColor(dotMid: number, r: RequestRecord, scale: number, colors: CostColors): string { const z0 = (r.cacheReadCost ?? 0) * scale; const z1 = z0 + (r.inputCost ?? 0) * scale; const z2 = z1 + (r.cacheWriteCost ?? 0) * scale; if (dotMid < z0) return colors.cacheRead; if (dotMid < z1) return colors.input; if (dotMid < z2) return colors.cacheWrite; return colors.output; } export function renderCostGraph( records: RequestRecord[], liveRecord: RequestRecord | null, width: number, grayscale = false, ): string[] { const allData = liveRecord ? [...records, liveRecord] : records; if (allData.length === 0) return []; const hasCosts = allData.some( r => r.inputCost != null || r.outputCost != null || r.cacheReadCost != null || r.cacheWriteCost != null, ); if (!hasCosts) return []; // Each braille char covers 2 data points (left + right column). const barWidth = Math.max(1, width); const data = allData.slice(-(barWidth * 2)); const maxCost = Math.max( 1e-9, ...allData.map(r => (r.cacheReadCost ?? 0) + (r.inputCost ?? 0) + (r.cacheWriteCost ?? 0) + (r.outputCost ?? 0), ), ); const scale = 8 / maxCost; const colors = grayscale ? COST_COLORS_GRAY : COST_COLORS; // Index of the braille char that contains the live record (rightmost char). const liveCharIdx = liveRecord ? Math.ceil(data.length / 2) - 1 : -1; let topLine = ""; let bottomLine = ""; for (let i = 0; i < data.length; i += 2) { const lastAlone = (data.length % 2 === 1) && (i === data.length - 1); const lRec = lastAlone ? null : data[i]!; const rRec = lastAlone ? data[i]! : (i + 1 < data.length ? data[i + 1]! : null); const totalCost = (r: RequestRecord) => (r.cacheReadCost ?? 0) + (r.inputCost ?? 0) + (r.cacheWriteCost ?? 0) + (r.outputCost ?? 0); const lDots = lRec ? Math.round(totalCost(lRec) * scale) : 0; const rDots = rRec ? Math.round(totalCost(rRec) * scale) : 0; const lBot = Math.min(4, lDots); const rBot = Math.min(4, rDots); const lTop = Math.max(0, lDots - 4); const rTop = Math.max(0, rDots - 4); // Use the right (newer) record for color, falling back to left. const repRec = rRec ?? lRec!; const charIdx = i / 2; const isLive = charIdx === liveCharIdx; const rBotMid = rBot / 2; const botColor = (lBot > 0 || rBot > 0) ? costZoneColor(rBotMid, repRec, scale, colors) : ""; const rTopMid = 4 + rTop / 2; const topColor = (lTop > 0 || rTop > 0) ? costZoneColor(rTopMid, repRec, scale, colors) : ""; const topCh = (lTop > 0 || rTop > 0) ? topColor + brailleChar(lTop, rTop) + ANSI_RESET : " "; const botCh = (lBot > 0 || rBot > 0) ? botColor + brailleChar(lBot, rBot) + ANSI_RESET : brailleChar(0, 0); topLine += isLive ? DIM + topCh + ANSI_RESET : topCh; bottomLine += isLive ? DIM + botCh + ANSI_RESET : botCh; } return [topLine, bottomLine]; } // ── Status bar ──────────────────────────────────────────────────────────────── export type StatusStyle = "dim" | "muted" | "warning" | "success" | "raw"; export type StatusPart = { text: string; style: StatusStyle; }; /** * Returns the status bar as an array of styled parts. The caller (extension or * test) decides how to apply the styles — the extension uses pi's theme.fg(), * tests can apply simple ANSI codes directly. */ export function buildStatusParts( records: RequestRecord[], sessionStartTime = 0, precision = 3, showCostPerReq = true, showCostPerMin = true, grayscale = false, ): StatusPart[] { if (records.length === 0) { return []; } const parts: StatusPart[] = []; if (records.length >= 1) { const window = records.slice(-3); const windowAvg = window.reduce((s, r) => s + r.cost, 0) / window.length; if (showCostPerReq) { parts.push({ text: `${formatCost(windowAvg, precision)}/req`, style: "muted" }); } const now = Date.now(); const elapsedMs = sessionStartTime > 0 ? now - sessionStartTime : 0; if (showCostPerMin && elapsedMs >= 5_000) { const WINDOW_MS = 60_000; const windowStart = sessionStartTime > 0 ? Math.max(now - WINDOW_MS, sessionStartTime) : now - WINDOW_MS; const windowRecords = records.filter(r => r.endTime >= windowStart); if (windowRecords.length > 0) { const windowCost = windowRecords.reduce((s, r) => s + r.cost, 0); const windowDurationMin = (now - windowStart) / 60_000; const costPerMin = windowCost / windowDurationMin; parts.push({ text: `${formatCost(costPerMin, precision)}/min`, style: "dim" }); } } const hasCostBreakdown = window.some( r => r.inputCost != null || r.outputCost != null || r.cacheReadCost != null || r.cacheWriteCost != null, ); if (hasCostBreakdown) { const avg = (fn: (r: RequestRecord) => number | undefined) => window.reduce((s, r) => s + (fn(r) ?? 0), 0) / window.length; const avgCacheRead = avg(r => r.cacheReadCost); const avgInput = avg(r => r.inputCost); const avgCacheWrite = avg(r => r.cacheWriteCost); const avgOutput = avg(r => r.outputCost); const c = grayscale ? COST_COLORS_GRAY : COST_COLORS; const fmt = (n: number) => formatCost(n, precision); const breakdown = `${c.cacheRead}\u25cfcr${ANSI_RESET}:${fmt(avgCacheRead)} ` + `${c.input}\u25cfin${ANSI_RESET}:${fmt(avgInput)} ` + `${c.cacheWrite}\u25cfcw${ANSI_RESET}:${fmt(avgCacheWrite)} ` + `${c.output}\u25cfout${ANSI_RESET}:${fmt(avgOutput)}`; parts.push({ text: breakdown, style: "raw" }); } } return parts; } // ── Shared formatting ───────────────────────────────────────────────────────── export function formatCost(usd: number, precision = 3): string { return `$${usd.toFixed(precision)}`; }