import type { Theme } from "@earendil-works/pi-coding-agent"; import type { AssistantUsageMetric } from "./types.js"; import { formatPercent, hitRateColor } from "./format-utils.js"; /** Below this leverage (cached tokens per fresh token) cache economics are poor. */ const LEVERAGE_FLOOR = 5; function formatIdle(ms: number): string { const min = Math.round(ms / 60_000); if (min >= 60) return `${(min / 60).toFixed(1).replace(/\.0$/, "")}h`; return `${min}m`; } function formatMoney(usd: number): string { if (usd >= 1) return `$${usd.toFixed(2)}`; if (usd >= 0.0005) return `$${usd.toFixed(3)}`; if (usd > 0) return "<$0.001"; return "$0"; } /** * Human-readable reasons a turn's cache prefix was broken. All signals are * computed in session-data; this only renders them. Tags intentionally * co-occur: a restart, for example, creates both a long idle gap and a * rebuilt prefix, and hiding the stronger signal behind the weaker one is * exactly how a restart miss gets mislabeled as pure cache expiry. * Ordering runs from recorded causes to evidence to conclusion. */ export function reasonTags(m: AssistantUsageMetric): string[] { const tags: string[] = []; if (m.modelChanged) tags.push("model switch"); if (m.thinkingChanged) tags.push("thinking change"); if (m.afterCompact) tags.push("after compact"); if (m.promptShrunk) tags.push("prefix shrank"); if (m.contextInvalidated) tags.push("context rebuilt"); // Precomputed in session-data against the TTL applicable to this model's // API protocol; the tag is rendered only when that verdict says expired. if (m.idleExpired === true) tags.push(`idle ${formatIdle(m.idleMs!)}`); return tags; } /** * Miss analysis: every turn below the 80% hit-rate threshold, worst first, * annotated with the likely cause and the estimated extra cost of the miss. * Long lists are browsed with j/k scroll (handled by the dialog). */ export function renderMissesView(theme: Theme, messages: AssistantUsageMetric[], _width: number): string[] { const lines: string[] = []; lines.push(theme.fg("accent", theme.bold("▎ Miss analysis"))); // A turn is listed only when the cacheable prefix was broken (miss ratio // above the cached-length-scaled threshold). Appended information — first // turns, searches, file reads, large tool outputs — never breaks the // prefix and is deliberately not listed. const misses = messages .filter((m) => m.missFlagged) .sort((a, b) => a.cacheHitPercent - b.cacheHitPercent); if (misses.length === 0) { lines.push(theme.fg("success", "No cache-prefix breaks detected — cache is healthy.")); return lines; } // Session-wide economics: total est. waste + cache leverage (cached tokens // served per fresh token paid). Same spirit as pi's cache-waste tracking. const totalWaste = misses.reduce((sum, m) => sum + (m.missedCost ?? 0), 0); const totalRead = messages.reduce((sum, m) => sum + m.cacheRead, 0); const totalFresh = messages.reduce((sum, m) => sum + m.input + m.cacheWrite, 0); const leverage = totalFresh > 0 ? totalRead / totalFresh : undefined; const summaryParts = [ `${misses.length} of ${messages.length} turns flagged (low hit rate or abnormal miss)`, ]; if (totalWaste > 0) summaryParts.push(`est. waste ${formatMoney(totalWaste)}`); if (leverage !== undefined) { const text = `leverage ${leverage.toFixed(1)}x`; summaryParts.push(leverage < LEVERAGE_FLOOR ? theme.fg("warning", text) : theme.fg("success", text)); } lines.push(theme.fg("dim", summaryParts.join(" · "))); lines.push(theme.fg("dim", "* = on current active branch")); for (const message of misses) { const label = `#${String(message.sequence).padStart(2, " ")}${message.isOnActiveBranch ? "*" : " "}`; const tags = reasonTags(message); const tagText = tags.length > 0 ? tags.join(" · ") : "—"; const mc = message.missedCost ?? 0; const cost = mc > 0 ? `+${formatMoney(mc)}` : ""; lines.push( `${theme.fg("muted", label)} ${hitRateColor(theme, message.cacheHitPercent, formatPercent(message.cacheHitPercent).padStart(6, " "))} ` + theme.fg("accent", cost.padEnd(10)) + theme.fg("dim", `${message.provider}/${message.model}`) + theme.fg("dim", ` ${tagText}`), ); } return lines; }