import type { Theme } from "@earendil-works/pi-coding-agent"; import type { AssistantUsageMetric } from "./types.js"; import { formatCompact, formatPercent, hitRateColor, shortModelName } from "./format-utils.js"; function pad(value: string, width: number, direction: "left" | "right" = "right"): string { if (value.length >= width) return value; const padding = " ".repeat(width - value.length); return direction === "left" ? padding + value : value + padding; } function truncate(value: string, width: number): string { if (value.length <= width) return value; return width <= 1 ? value.slice(0, width) : `${value.slice(0, Math.max(0, width - 1))}…`; } function buildRow( theme: Theme, metric: AssistantUsageMetric, cumPrompt: number, includeEntryId: boolean, includeTimestamp: boolean, hideWrite: boolean, ): string { const cols = [ pad(String(metric.sequence), 4, "left"), pad(metric.isOnActiveBranch ? "*" : " ", 1), pad(truncate(shortModelName(metric.provider, metric.model), 24), 24), pad(formatCompact(metric.input + metric.cacheRead + metric.cacheWrite), 9, "left"), pad(formatCompact(metric.output), 9, "left"), pad(formatCompact(metric.cacheRead), 9, "left"), ]; if (!hideWrite) cols.push(pad(formatCompact(metric.cacheWrite), 9, "left")); cols.push( hitRateColor(theme, metric.cacheHitPercent, pad(formatPercent(metric.cacheHitPercent), 7, "left")), ); cols.push(pad(formatCompact(cumPrompt), 9, "left")); if (includeEntryId) cols.splice(2, 0, pad(truncate(metric.entryId, 8), 8)); if (includeTimestamp) cols.splice(includeEntryId ? 3 : 2, 0, pad(metric.timestamp.slice(11, 19), 8)); return cols.join(" "); } function buildHeader(includeEntryId: boolean, includeTimestamp: boolean, hideWrite: boolean): string { const cols = [ pad("#", 4, "left"), pad("B", 1), pad("model", 24), pad("prompt", 9, "left"), pad("recv", 9, "left"), pad("hit", 9, "left"), ]; if (!hideWrite) cols.push(pad("write", 9, "left")); cols.push(pad("hit%", 7, "left")); cols.push(pad("cumTok", 9, "left")); if (includeEntryId) cols.splice(2, 0, pad("entry", 8)); if (includeTimestamp) cols.splice(includeEntryId ? 3 : 2, 0, pad("time", 8)); return cols.join(" "); } const EDGE_ROWS = 5; /** * Renders the per-message breakdown table. Long sessions show only the first * and last EDGE_ROWS messages — the middle is collapsed into a single * omission line, so the table stays readable without scrolling. * The session-wide summary is rendered once by the dialog header, not here. */ export function renderStatsView(theme: Theme, messages: AssistantUsageMetric[], width: number): string[] { const lines: string[] = []; if (messages.length === 0) { lines.push( theme.fg("warning", "No assistant messages with usage data are available yet in this session."), ); return lines; } lines.push(theme.fg("accent", theme.bold("▎ Message table"))); lines.push(theme.fg("dim", "B = message is on the current active branch")); const includeEntryId = width >= 92; const includeTimestamp = width >= 104; // Providers without a cache-write bucket (DeepSeek/OpenAI/Gemini) always // report 0; hide the column instead of printing an all-zero column. const hideWrite = messages.every((m) => m.cacheWrite === 0); const header = buildHeader(includeEntryId, includeTimestamp, hideWrite); lines.push(theme.fg("muted", header)); lines.push(theme.fg("dim", "-".repeat(Math.min(header.length, Math.max(20, width - 2))))); const showAll = messages.length <= EDGE_ROWS * 2; if (!showAll) { lines.push( theme.fg("dim", `Showing first & last ${EDGE_ROWS} of ${messages.length} messages`), ); } // Running cumulative prompt tokens (input + cacheRead + cacheWrite). const cumPrompt: number[] = []; let acc = 0; for (const m of messages) { acc += m.input + m.cacheRead + m.cacheWrite; cumPrompt.push(acc); } for (const [i, metric] of (showAll ? messages : messages.slice(0, EDGE_ROWS)).entries()) { lines.push(buildRow(theme, metric, cumPrompt[i]!, includeEntryId, includeTimestamp, hideWrite)); } if (!showAll) { const omitted = messages.length - EDGE_ROWS * 2; const firstOmitted = messages[EDGE_ROWS]!; const lastOmitted = messages[messages.length - EDGE_ROWS - 1]!; lines.push( theme.fg( "dim", ` … ${omitted} messages omitted (seq ${firstOmitted.sequence}–${lastOmitted.sequence}) …`, ), ); for (const [i, metric] of messages.slice(-EDGE_ROWS).entries()) { lines.push( buildRow(theme, metric, cumPrompt[messages.length - EDGE_ROWS + i]!, includeEntryId, includeTimestamp, hideWrite), ); } } return lines; }