Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 115 additions & 53 deletions lib/statistics-view.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
// Statistics overlay view (epic #1, I8).
// Statistics overlay view (epic #1, I8; panel polish).
//
// The panel cannot be a sidebar rail from an extension (the painted section
// list is hardcoded and the header/Status slots are singletons), so it is the
// supported overlay: `ctx.ui.custom(..., { overlay: true })`. This module is
// the pure half — a model built from I4's aggregate and I5's timeline, and a
// The panel cannot be a sidebar rail from an extension, so it is the supported
// overlay: `ctx.ui.custom(..., { overlay: true })`. This module is the pure
// half — a model built from I4's aggregate and I5's timeline, and a
// width-aware renderer — plus the component that owns keyboard handling.
//
// Non-blocking: `render` reads the model it was handed and never recomputes an
// aggregate. The extension caches one snapshot and invalidates it on events.
//
// Every figure carries its provenance, and an unavailable value renders `n/a`,
// never `0`.
// It is written for a human, not for a machine: readable magnitudes, no raw
// ids, provenance as a single marker with a legend, and no silently truncated
// line. An unavailable value renders `n/a`, never `0`, and the overlay renders
// a cached model it never recomputes per frame.

import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { NANO_USD_SCALE } from "./session-usage.ts";
Expand All @@ -19,6 +17,8 @@ import type { Timeline } from "./session-timeline.ts";

/** Below this width the panel drops the frame and stacks its sections. */
export const STATISTICS_NARROW_WIDTH = 60;
/** The documented marker convention, shown once in the footer. */
export const STATISTICS_LEGEND = "measured unless + partial · n/a unavailable";

export interface StatisticsStyle {
readonly title: (text: string) => string;
Expand All @@ -31,9 +31,14 @@ export interface StatisticsStyle {
export const PLAIN_STATISTICS_STYLE: StatisticsStyle = { title: (text) => text, frame: (text) => text, dim: (text) => text, accent: (text) => text };

export interface StatisticsRow {
readonly label: string;
/** Stable key, never displayed. */
readonly key: string;
/** Displayed primary label: the agent role, or the model. */
readonly title: string;
/** Displayed secondary label: the task label, or the provider. */
readonly subtitle?: string;
readonly turns: number;
readonly cost: string;
readonly costNanoUsd: number;
readonly costProvenance: FigureProvenance;
readonly tokens: number;
}
Expand All @@ -51,7 +56,7 @@ export interface StatisticsTimelineModel {
}

export interface StatisticsModel {
readonly cost: string;
readonly costNanoUsd: number;
readonly costProvenance: FigureProvenance;
readonly costAbsent: number;
readonly turns: number;
Expand All @@ -63,37 +68,60 @@ export interface StatisticsModel {
readonly generatedAt: number;
}

function usd(nanoUsd: number): string {
return (nanoUsd / NANO_USD_SCALE).toFixed(6);
function usdFromNano(nanoUsd: number): string {
return `$${(nanoUsd / NANO_USD_SCALE).toFixed(4)}`;
}

function usd(value: number | null): string {
if (value === null) return "n/a";
if (value >= 1) return `$${value.toFixed(2)}`;
if (value >= 0.01) return `$${value.toFixed(3)}`;
return `$${value.toFixed(4)}`;
}

/** Compact magnitude: 122803929 becomes "122.8M", 7780 becomes "7.8K". */
export function formatTokens(value: number | null): string {
if (value === null) return "n/a";
if (value >= 1e9) return `${trimZero(value / 1e9)}B`;
if (value >= 1e6) return `${trimZero(value / 1e6)}M`;
if (value >= 1e3) return `${trimZero(value / 1e3)}K`;
return `${Math.round(value)}`;
}

function trimZero(value: number): string {
return value.toFixed(1).replace(/\.0$/, "");
}

function percent(value: number | null): string {
return value === null ? "n/a" : `${(value * 100).toFixed(1)}%`;
}

/** A ratio is derived; an unavailable ratio is explicit, never a blank. */
function ratio(value: number | null): string {
return value === null ? "n/a" : value.toFixed(4);
return value === null ? "n/a" : value.toFixed(2);
}

function rowFrom(label: string, bucket: UsageBucket): StatisticsRow {
return { label, turns: bucket.turns, cost: usd(bucket.cost.nanoUsd), costProvenance: bucket.cost.provenance, tokens: bucket.tokens.total };
function rowFrom(key: string, title: string, subtitle: string | undefined, bucket: UsageBucket): StatisticsRow {
return { key, title, ...(subtitle !== undefined && subtitle.length > 0 ? { subtitle } : {}), turns: bucket.turns, costNanoUsd: bucket.cost.nanoUsd, costProvenance: bucket.cost.provenance, tokens: bucket.tokens.total };
}

/** Build the display model from the aggregate and the optional timeline. Pure. */
export function buildStatisticsModel(aggregate: UsageAggregate, timeline: Timeline | null, generatedAt: number): StatisticsModel {
return {
cost: usd(aggregate.cost.nanoUsd),
costNanoUsd: aggregate.cost.nanoUsd,
costProvenance: aggregate.cost.provenance,
costAbsent: aggregate.cost.absent,
turns: aggregate.turns,
tokens: aggregate.tokens,
ratios: [
{ label: "cache-read share", value: ratio(aggregate.ratios.cacheReadShare) },
{ label: "cache-write share", value: ratio(aggregate.ratios.cacheWriteShare) },
{ label: "reasoning/output", value: ratio(aggregate.ratios.reasoningShareOfOutput) },
{ label: "output/total", value: ratio(aggregate.ratios.outputToTotal) },
{ label: "cost/turn", value: ratio(aggregate.ratios.costPerTurn) },
{ label: "tokens/turn", value: ratio(aggregate.ratios.tokensPerTurn) },
{ label: "cache reads", value: percent(aggregate.ratios.cacheReadShare) },
{ label: "cache writes", value: percent(aggregate.ratios.cacheWriteShare) },
{ label: "reasoning of output", value: percent(aggregate.ratios.reasoningShareOfOutput) },
{ label: "output of total", value: percent(aggregate.ratios.outputToTotal) },
{ label: "cost per turn", value: usd(aggregate.ratios.costPerTurn) },
{ label: "tokens per turn", value: formatTokens(aggregate.ratios.tokensPerTurn) },
],
subagents: aggregate.perSubagent.map((entry) => rowFrom(`${entry.taskId} ${entry.agent}`, entry)),
models: aggregate.perModel.map((entry) => rowFrom(`${entry.provider}/${entry.model}`, entry)),
subagents: aggregate.perSubagent.map((entry) => rowFrom(entry.taskId, entry.agent, entry.label, entry)),
models: aggregate.perModel.map((entry) => rowFrom(`${entry.provider}/${entry.model}`, entry.model, entry.provider, entry)),
timeline: timeline ? { modelMs: timeline.modelMs, toolMs: timeline.toolMs, idleMs: timeline.idleMs, wallClockMs: timeline.wallClockMs } : null,
generatedAt,
};
Expand All @@ -109,60 +137,94 @@ function formatDuration(ms: number): string {
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) return `${hours}h ${minutes}m`;
if (minutes > 0) return `${minutes}m ${seconds}s`;
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
return `${seconds}s`;
}

function costLine(model: StatisticsModel): string {
return model.costProvenance === "measured" ? `$${model.cost}` : `$${model.cost} + (${model.costAbsent} unreported)`;
/** Join parts into lines that never exceed `width`, instead of truncating. */
function wrapParts(parts: readonly string[], width: number, separator = " · "): string[] {
const lines: string[] = [];
let current = "";
for (const part of parts) {
const candidate = current === "" ? part : `${current}${separator}${part}`;
if (visibleWidth(candidate) <= width || current === "") current = candidate;
else {
lines.push(current);
current = part;
}
}
if (current !== "") lines.push(current);
return lines;
}

function ratioLine(model: StatisticsModel): string {
return model.ratios.map((entry) => `${entry.label} ${entry.value}`).join(" ");
function costText(model: StatisticsModel): string {
const marker = model.costProvenance === "partial" ? "+" : "";
return `${usdFromNano(model.costNanoUsd)}${marker}`;
}

function timeSplit(model: StatisticsModel): string {
if (!model.timeline) return "n/a";
return `model ${formatDuration(model.timeline.modelMs)} · tools ${formatDuration(model.timeline.toolMs)} · idle ${formatDuration(model.timeline.idleMs)} (est.)`;
}

function rowLines(row: StatisticsRow, width: number, style: StatisticsStyle): string[] {
const lines: string[] = [` ${style.accent(row.title)}${row.subtitle ? style.dim(` ${row.subtitle}`) : ""}`];
const marker = row.costProvenance === "partial" ? "+" : "";
lines.push(` ${wrapParts([`${row.turns} turn${row.turns === 1 ? "" : "s"}`, `${usdFromNano(row.costNanoUsd)}${marker}`, `${formatTokens(row.tokens)} tokens`], width - 4).join("\n ")}`);
return lines;
}

/** The stacked, frameless layout used below `STATISTICS_NARROW_WIDTH`. */
function renderNarrow(model: StatisticsModel, width: number, style: StatisticsStyle): string[] {
const lines: string[] = [];
lines.push(style.title("Session statistics"));
lines.push(`Cost ${costLine(model)} [${model.costProvenance}]`);
lines.push(`Turns ${model.turns} Tokens ${model.tokens.total}`);
lines.push(`Timeline ${model.timeline ? `${formatDuration(model.timeline.modelMs)} model / ${formatDuration(model.timeline.toolMs)} tool / ${formatDuration(model.timeline.idleMs)} idle (est.)` : "n/a"}`);
lines.push(style.dim(ratioLine(model)));
lines.push(style.accent("Subagents"));
for (const line of wrapParts([costText(model), `${model.turns} turns`, `${formatTokens(model.tokens.total)} tokens`, model.timeline ? formatDuration(model.timeline.wallClockMs) : "n/a"], width)) lines.push(line);
if (model.timeline) for (const line of wrapParts([`model ${formatDuration(model.timeline.modelMs)}`, `tools ${formatDuration(model.timeline.toolMs)}`, `idle ${formatDuration(model.timeline.idleMs)} (est.)`], width)) lines.push(style.dim(line));
lines.push("");
lines.push(style.accent("Helpers"));
if (model.subagents.length === 0) lines.push(style.dim(" none"));
for (const row of model.subagents) lines.push(` ${row.label} ${row.turns}t $${row.cost} [${row.costProvenance}]`);
for (const row of model.subagents) lines.push(...rowLines(row, width, style));
lines.push("");
lines.push(style.accent("Models"));
if (model.models.length === 0) lines.push(style.dim(" none"));
for (const row of model.models) lines.push(` ${row.label} ${row.turns}t $${row.cost} [${row.costProvenance}]`);
for (const row of model.models) lines.push(...rowLines(row, width, style));
lines.push("");
lines.push(style.accent("Efficiency"));
for (const line of wrapParts(model.ratios.map((entry) => `${entry.label} ${entry.value}`), width)) lines.push(style.dim(` ${line}`));
lines.push("");
lines.push(style.dim("e Export r Refresh Esc/q Close"));
lines.push(style.dim(STATISTICS_LEGEND));
return lines.map((line) => fit(line, width));
}

/** The framed, two-column layout used at and above `STATISTICS_NARROW_WIDTH`. */
/** The framed layout used at and above `STATISTICS_NARROW_WIDTH`. */
function renderWide(model: StatisticsModel, width: number, style: StatisticsStyle): string[] {
const inner = Math.max(1, width - 4);
const content: string[] = [];
content.push(`${style.accent("Cost")} ${costLine(model)} [${model.costProvenance}]`);
content.push(`${style.accent("Turns")} ${model.turns} ${style.accent("Tokens")} ${model.tokens.total} ${style.accent("Wall")} ${model.timeline ? formatDuration(model.timeline.wallClockMs) : "n/a"}`);
content.push(`${style.accent("Model")} ${model.timeline ? formatDuration(model.timeline.modelMs) : "n/a"} ${style.accent("Tool")} ${model.timeline ? formatDuration(model.timeline.toolMs) : "n/a"} ${style.accent("Idle")} ${model.timeline ? `${formatDuration(model.timeline.idleMs)} (est.)` : "n/a"}`);
content.push(style.dim(ratioLine(model)));
content.push("");
content.push(style.accent("Subagents"));
if (model.subagents.length === 0) content.push(style.dim(" none"));
for (const row of model.subagents) content.push(` ${fit(row.label, 32)} ${String(row.turns).padStart(3)}t $${row.cost} [${row.costProvenance}] ${row.tokens} tok`);
for (const line of wrapParts([costText(model), `${model.turns} turns`, `${formatTokens(model.tokens.total)} tokens`, model.timeline ? formatDuration(model.timeline.wallClockMs) : "n/a"], inner)) content.push(` ${line}`);
if (model.timeline) content.push(` ${style.dim(timeSplit(model))}`);
content.push("");
content.push(style.accent(" Helpers"));
if (model.subagents.length === 0) content.push(style.dim(" none"));
for (const row of model.subagents) content.push(...rowLines(row, inner, style));
content.push("");
content.push(style.accent(" Models"));
if (model.models.length === 0) content.push(style.dim(" none"));
for (const row of model.models) content.push(...rowLines(row, inner, style));
content.push("");
content.push(style.accent(" Efficiency"));
for (const line of wrapParts(model.ratios.map((entry) => `${entry.label} ${entry.value}`), inner - 4)) content.push(style.dim(` ${line}`));
content.push("");
content.push(style.accent("Models"));
if (model.models.length === 0) content.push(style.dim(" none"));
for (const row of model.models) content.push(` ${fit(row.label, 32)} ${String(row.turns).padStart(3)}t $${row.cost} [${row.costProvenance}] ${row.tokens} tok`);

const title = " Session statistics ";
const top = `${style.frame("╭─")}${style.title(title)}${style.frame(`${"─".repeat(Math.max(0, width - 3 - visibleWidth(title)))}╮`)}`;
const body = content.map((line) => `${style.frame("│ ")}${fit(line, inner)}${style.frame(" │")}`);
const footer = `${style.frame("│ ")}${fit(style.dim("e Export r Refresh Esc/q Close"), inner)}${style.frame(" │")}`;
const legend = `${style.frame("│ ")}${fit(style.dim(STATISTICS_LEGEND), inner)}${style.frame(" │")}`;
const bottom = style.frame(`╰${"─".repeat(Math.max(0, width - 2))}╯`);
return [top, ...body, footer, bottom];
return [top, ...body, footer, legend, bottom];
}

/** Render the model at `width`: framed when wide, stacked when narrow. */
Expand Down
Loading
Loading