From 334e2485a1b3c075c9f2fe7e6f4efa0c813d47be Mon Sep 17 00:00:00 2001 From: Eduardo Firvida Date: Wed, 30 Sep 2026 06:03:49 -0300 Subject: [PATCH] refactor(shell): make the statistics panel readable instead of technical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first panel was a data dump: `[measured]` on every row, the raw task id where the agent role belongs, nine-digit token counts, and an efficiency line that silently truncated. Rework it for a human. - Readable magnitudes: tokens render as 122.8M / 7.8K, and a cost as $0.82 or $0.0012 by size, instead of 122803929 and six decimals. - No raw ids: a helper row shows the agent role and its task label (the task id stays a key, never a label), and a model row shows the model with the provider as a dimmed suffix. - Provenance as one marker plus a footer legend (`measured unless + partial · n/a unavailable`) instead of a bracket on every figure. - The efficiency ratios wrap into lines that fit, so no label is ever truncated; `n/a` still marks an unavailable value, never 0. - Friendlier section names ("Helpers") and a compact time split. Pure model and renderer unchanged in contract: `buildStatisticsModel`, `renderStatistics` and `StatisticsView` keep their shape, and the overlay still renders a cached model it never recomputes per frame. --- lib/statistics-view.ts | 168 +++++++++++++++++++++++----------- tests/statistics-view.test.ts | 51 +++++++---- 2 files changed, 150 insertions(+), 69 deletions(-) diff --git a/lib/statistics-view.ts b/lib/statistics-view.ts index d7b13516b..46fb209cd 100644 --- a/lib/statistics-view.ts +++ b/lib/statistics-view.ts @@ -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"; @@ -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; @@ -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; } @@ -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; @@ -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, }; @@ -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. */ diff --git a/tests/statistics-view.test.ts b/tests/statistics-view.test.ts index ccab17cb8..b8fab366c 100644 --- a/tests/statistics-view.test.ts +++ b/tests/statistics-view.test.ts @@ -4,11 +4,11 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { aggregateUsage, type AggregationRecord } from "../lib/session-aggregate.ts"; import { buildTimeline } from "../lib/session-timeline.ts"; import { ABSENT_COST, reportedCost, type UsageTokens } from "../lib/session-usage.ts"; -import { buildStatisticsModel, renderStatistics, STATISTICS_NARROW_WIDTH, StatisticsView } from "../lib/statistics-view.ts"; +import { buildStatisticsModel, formatTokens, renderStatistics, STATISTICS_LEGEND, STATISTICS_NARROW_WIDTH, StatisticsView } from "../lib/statistics-view.ts"; // I8: the statistics overlay view. A width-aware renderer over a cached model, -// provenance on every figure, `n/a` for an unavailable value, and the overlay -// keys. `render` never recomputes an aggregate. +// written for a human: readable magnitudes, no raw ids, provenance as one +// marker with a legend, `n/a` for an unavailable value, and no truncated line. function tokens(partial: Partial): UsageTokens { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0, ...partial }; @@ -17,7 +17,7 @@ function tokens(partial: Partial): UsageTokens { const FIXTURE: readonly AggregationRecord[] = [ { source: "parent", sessionId: "s1", project: "projA", timestamp: 1000, model: "m1", provider: "p1", tokens: tokens({ input: 100, output: 40, cacheRead: 30, cacheWrite: 5, reasoning: 8, total: 175 }), cost: reportedCost(0.001) }, { source: "parent", sessionId: "s1", project: "projA", timestamp: 2000, model: "m1", provider: "p1", tokens: tokens({}), cost: ABSENT_COST }, - { source: "subagent", sessionId: "s1", project: "projA", timestamp: 3000, model: "m2", provider: "p1", tokens: tokens({ input: 200, output: 60, cacheRead: 10, cacheWrite: 5, reasoning: 20, total: 275 }), cost: reportedCost(0.002), task: { taskId: "S1", agent: "gentle-ai-worker", label: "build", status: "completed", toolCalls: 7 } }, + { source: "subagent", sessionId: "s1", project: "projA", timestamp: 3000, model: "m2", provider: "p1", tokens: tokens({ input: 200, output: 60, cacheRead: 10, cacheWrite: 5, reasoning: 20, total: 275 }), cost: reportedCost(0.002), task: { taskId: "S1", agent: "gentle-ai-worker", label: "build the widget", status: "completed", toolCalls: 7 } }, ]; const AGGREGATE = aggregateUsage(FIXTURE, { now: () => 0 }); @@ -29,25 +29,36 @@ const TIMELINE = buildTimeline([ { kind: "assistant", timestamp: 6000, model: "m1", toolCalls: [] }, ]); -test("the model carries provenance, ratios and the per-subagent and per-model rows", () => { +test("the model carries provenance, ratios and human rows with the task id kept out of the label", () => { const model = buildStatisticsModel(AGGREGATE, TIMELINE, 42); - assert.equal(model.cost, "0.003000"); + assert.equal(model.costNanoUsd, 3_000_000); assert.equal(model.costProvenance, "partial"); assert.equal(model.costAbsent, 1); assert.equal(model.turns, 3); assert.equal(model.tokens.total, 450); - assert.deepEqual(model.subagents.map((row) => row.label), ["S1 gentle-ai-worker"]); - assert.deepEqual(model.models.map((row) => row.label), ["p1/m1", "p1/m2"]); + assert.deepEqual(model.subagents.map((row) => row.title), ["gentle-ai-worker"]); + assert.deepEqual(model.subagents.map((row) => row.key), ["S1"], "the id is a key, not a label"); + assert.equal(model.subagents[0]!.subtitle, "build the widget"); + assert.deepEqual(model.models.map((row) => row.title), ["m1", "m2"]); + assert.deepEqual(model.models.map((row) => row.subtitle), ["p1", "p1"]); assert.equal(model.generatedAt, 42); assert.deepEqual(model.timeline, { modelMs: 3000, toolMs: 3000, idleMs: 0, wallClockMs: 6000 }); - assert.equal(model.ratios.find((entry) => entry.label === "cache-read share")!.value, "0.0889"); + assert.equal(model.ratios.find((entry) => entry.label === "cache reads")!.value, "8.9%"); + assert.equal(model.ratios.find((entry) => entry.label === "tokens per turn")!.value, "150"); +}); + +test("formatTokens renders readable magnitudes", () => { + assert.equal(formatTokens(999), "999"); + assert.equal(formatTokens(7_780), "7.8K"); + assert.equal(formatTokens(122_803_929), "122.8M"); + assert.equal(formatTokens(1_500_000_000), "1.5B"); + assert.equal(formatTokens(null), "n/a"); }); test("an unavailable figure renders n/a, never 0", () => { const model = buildStatisticsModel(EMPTY, null, 0); assert.equal(model.ratios.every((entry) => entry.value === "n/a"), true); - const wide = renderStatistics(model, 100).join("\n"); - assert.match(wide, /n\/a/); + assert.match(renderStatistics(model, 100).join("\n"), /n\/a/); assert.equal(model.timeline, null); }); @@ -57,21 +68,29 @@ test("the narrow layout stacks its sections and fits the width", () => { assert.equal(lines.every((line) => visibleWidth(line) <= width), true, `every line fits ${width}`); assert.equal(lines[0]!.trimEnd(), "Session statistics"); assert.equal(lines.some((line) => line.includes("╭")), false, "no frame when narrow"); - assert.equal(lines.some((line) => line.includes("Subagents")), true); + assert.equal(lines.some((line) => line.includes("Helpers")), true); assert.equal(lines.some((line) => line.includes("Export")), true); } }); -test("the fullscreen layout is framed and every line is exactly the width", () => { +test("the fullscreen layout is framed, exact width, and never truncates a ratio", () => { for (const width of [STATISTICS_NARROW_WIDTH, 80, 120]) { const lines = renderStatistics(buildStatisticsModel(AGGREGATE, TIMELINE, 0), width); assert.equal(lines[0]!.startsWith("╭"), true); assert.equal(lines[lines.length - 1]!.startsWith("╰"), true); assert.equal(lines.every((line) => visibleWidth(line) === width), true, `every line is ${width} wide`); const text = lines.join("\n"); - assert.match(text, /partial/); - assert.match(text, /S1 gentle-ai-worker/); - assert.match(text, /p1\/m1/); + assert.match(text, /gentle-ai-worker/); + assert.match(text, /build the widget/); + assert.match(text, /m1/); + assert.equal(text.includes("S1"), false, "the raw task id never reaches the panel"); + assert.equal(text.includes("[measured]"), false, "no noisy bracket on every row"); + assert.match(text, /\+/, "a partial cost is marked with +"); + assert.equal(text.includes(STATISTICS_LEGEND), true, "the marker convention is shown once"); + // Every efficiency label survives at any wide width: the ratios wrap, never truncate. + for (const label of ["cache reads", "cache writes", "reasoning of output", "output of total", "cost per turn", "tokens per turn"]) { + assert.equal(text.includes(label), true, `${label} must not be truncated at width ${width}`); + } } });