diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 76a6d9f..1a1a864 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { renderNpubs, renderRecent } from "./render.ts"; +import { renderNpubs, renderRecent, renderStackedBar, tokenSegments } from "./render.ts"; import { stripAnsi } from "./terminal.ts"; +import { COLORS } from "./constants.ts"; import { buildClientNaming, resolveClientLabel, type ClientInfo, type NpubEntry } from "./data.ts"; import type { UsageStats } from "./types.ts"; @@ -141,3 +142,115 @@ describe("renderRecent", () => { expect(out).toContain("pi-agent"); }); }); + +describe("renderStackedBar", () => { + test("splits the track in proportion to the segment values", () => { + const bar = stripAnsi(renderStackedBar( + [{ value: 9000, color: COLORS.green }, { value: 1000, color: COLORS.red }, { value: 2500, color: COLORS.blue }], + 20, + )); + expect(bar).toBe("█".repeat(20)); + expect(bar.length).toBe(20); + }); + + test("hands rounding leftovers to the largest remainders so the track is always full", () => { + const bar = renderStackedBar( + [{ value: 1, color: COLORS.green }, { value: 1, color: COLORS.red }, { value: 1, color: COLORS.blue }], + 10, + ); + // 10/3 = 3.33 -> 3/3/3 plus one leftover cell, coloured with the reset only once. + expect(bar.startsWith(COLORS.green + "█".repeat(4) + COLORS.red + "█".repeat(3) + COLORS.blue + "█".repeat(3))).toBe(true); + expect(bar.endsWith(COLORS.reset)).toBe(true); + expect(stripAnsi(bar).length).toBe(10); + }); + + test("renders an empty track when there are no tokens", () => { + expect(renderStackedBar([{ value: 0, color: COLORS.green }], 6)).toBe(" ".repeat(6)); + }); +}); + +describe("tokenSegments", () => { + test("splits the prompt into cache read and everything that was not cached", () => { + expect(tokenSegments({ promptTokens: 10_000, completionTokens: 2500, cacheReadInputTokens: 9000, cacheCreationInputTokens: 1000 })) + .toEqual({ cacheRead: 9000, notCached: 1000, input: 10_000, output: 2500, total: 12_500 }); + }); + + test("counts uncached input alongside cache writes when the cache counts are extra", () => { + expect(tokenSegments({ promptTokens: 100, completionTokens: 20, cacheReadInputTokens: 5000, cacheCreationInputTokens: 300 })) + .toEqual({ cacheRead: 5000, notCached: 400, input: 5400, output: 20, total: 5420 }); + }); + + test("reports a fully cache-read prompt with no not-cached input", () => { + expect(tokenSegments({ promptTokens: 128_000, completionTokens: 42, cacheReadInputTokens: 128_000 })) + .toEqual({ cacheRead: 128_000, notCached: 0, input: 128_000, output: 42, total: 128_042 }); + }); + + test("tolerates entries without cache or token fields", () => { + expect(tokenSegments({})).toEqual({ cacheRead: 0, notCached: 0, input: 0, output: 0, total: 0 }); + }); +}); + +describe("renderRecent token bars", () => { + const stats = { + ...statsFor(NPUB), + entries: [ + { + id: "1", + timestamp: Date.now(), + modelId: "gpt-5.4", + baseUrl: "https://api.example.com", + provider: "openai", + requestId: "r1", + cost: 0, + satsCost: 1, + inputMsats: 1000, + outputMsats: 2000, + totalMsats: 3000, + promptTokens: 10_000, + completionTokens: 2500, + totalTokens: 12_500, + cacheReadInputTokens: 9000, + cacheCreationInputTokens: 1000, + client: "pi-agent", + }, + ], + } as unknown as UsageStats; + + test("draws a green/red bar per row with input/output tokens beside it", () => { + const out = renderRecent(stats, 120, buildClientNaming([], [])); + + // 9000 cache read / 1000 not cached out of 10K input on a 20-cell track. + expect(out).toContain(COLORS.green + "█".repeat(18) + COLORS.red + "█".repeat(2) + COLORS.reset); + // No blue (output) segment: output is reported as the second number. + expect(out).not.toContain(COLORS.blue + "█"); + // IN and OUT are pinned to opposite ends of the column. + expect(stripAnsi(out)).toContain("10.0K - 2.5K"); + expect(stripAnsi(out)).toContain("IN - OUT"); + // The bar is labelled `CACHE HIT` and no longer carries a legend line. + expect(stripAnsi(out)).toContain("CACHE HIT"); + expect(stripAnsi(out)).not.toContain("bars:"); + }); + + test("shows only the total cost in sats, not the input/output breakdown", () => { + const out = stripAnsi(renderRecent(stats, 120, buildClientNaming([], []))); + + expect(out).toContain("COST"); + // 3000 total msats -> 3.00 sats; the 1.00/2.00 input/output split is gone. + expect(out).toContain("3.00 sats"); + expect(out).not.toContain("1.00 sats"); + expect(out).not.toContain("2.00 sats"); + + // The header is right-aligned: it ends at the same column as the values. + const lines = out.split("\n"); + const headerEnd = lines[1]!.indexOf("COST") + "COST".length; + const valueEnd = lines[3]!.indexOf("3.00 sats") + "3.00 sats".length; + expect(headerEnd).toBe(valueEnd); + }); + + test("keeps every row the same visible width as the box", () => { + for (const width of [200, 160, 140, 120, 110, 100, 90, 80, 70]) { + const lines = stripAnsi(renderRecent(stats, width, buildClientNaming([], []))).split("\n"); + expect(lines.map((line) => line.length)).toEqual(lines.map(() => width)); + } + }); +}); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 5c992fb..c6fcae5 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -585,14 +585,111 @@ function truncateNpub(npub: string): string { return npub.slice(0, 10) + "…" + npub.slice(-6); } +/** + * Token breakdown of a single request, split the way the TUI colours it: + * input read from the prompt cache (green) vs input that was not (red, + * i.e. cache writes plus uncached input), plus output tokens. + */ +export interface TokenSegments { + /** Input tokens served from the prompt cache. */ + cacheRead: number; + /** Input tokens that were not served from the cache. */ + notCached: number; + /** Total input tokens, cached and not. */ + input: number; + output: number; + /** Input + output. */ + total: number; +} + +/** + * Split a usage entry into cache read / not-cached input and output tokens. + * + * Providers report prompt tokens inconsistently: OpenAI-style `prompt_tokens` + * already includes the cached subsets, while Anthropic-style `input_tokens` + * reports them alongside. If the prompt is at least as large as the cached + * counts it is treated as containing them (OpenAI); otherwise the cached + * counts are extra (Anthropic). `input` is the sum of what is drawn rather + * than a possibly under-counted `totalTokens`. + */ +export function tokenSegments(entry: { + promptTokens?: number; + completionTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; +}): TokenSegments { + const cacheRead = Math.max(0, entry.cacheReadInputTokens || 0); + const cacheWrite = Math.max(0, entry.cacheCreationInputTokens || 0); + const prompt = Math.max(0, entry.promptTokens || 0); + const output = Math.max(0, entry.completionTokens || 0); + const cached = cacheRead + cacheWrite; + const uncachedInput = prompt >= cached ? prompt - cached : prompt; + const notCached = cacheWrite + uncachedInput; + const input = cacheRead + notCached; + return { cacheRead, notCached, input, output, total: input + output }; +} + +/** One coloured slice of a {@link renderStackedBar}. */ +export interface BarSegment { + /** Segment magnitude; negatives are treated as zero. */ + value: number; + /** ANSI escape applied to this segment's cells. */ + color: string; +} + +/** + * Render a fixed-width stacked bar: `trackWidth` cells split between the + * segments in proportion to their values, so the bar conveys *composition* + * (magnitude is reported separately, e.g. as the token total beside it). + * + * Cell counts use largest-remainder rounding, so the segments always fill + * exactly `trackWidth` cells. A row with no tokens renders as an empty track. + */ +export function renderStackedBar(segments: BarSegment[], trackWidth: number): string { + const track = Math.max(0, Math.floor(trackWidth)); + if (track === 0) return ""; + + const values = segments.map((segment) => Math.max(0, segment.value)); + const total = values.reduce((sum, value) => sum + value, 0); + if (total <= 0) return " ".repeat(track); + + const exact = values.map((value) => (value / total) * track); + const cells = exact.map((value) => Math.floor(value)); + let remaining = track - cells.reduce((sum, value) => sum + value, 0); + + // Largest-remainder: leftover cells go to the biggest fractional parts. + const remainders = exact + .map((value, index) => ({ index, frac: value - Math.floor(value) })) + .sort((a, b) => b.frac - a.frac); + for (let i = 0; i < remainders.length && remaining > 0; i++, remaining--) { + cells[remainders[i]!.index]! += 1; + } + + const bar = segments + .map((segment, i) => (cells[i]! > 0 ? `${segment.color}${"█".repeat(cells[i]!)}` : "")) + .join(""); + return bar.length > 0 ? bar + COLORS.reset : " ".repeat(track); +} + +/** Colour coding for the Recent tab's cache-hit bars. */ +const TOKEN_BAR_COLORS = { + cacheRead: COLORS.green, + notCached: COLORS.red, +}; + export function renderRecent(stats: UsageStats, width: number, naming: ClientNaming): string { const recentEntries = stats.entries.slice(0, 50); if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests"); - const timeCol = 10; - const modelCol = 18; - const tokensCol = 18; - const costCol = 18; + const timeCol = 8; + const costCol = 12; + // Width reserved left of the token counts for the cache-hit bar. `CACHE HIT` + // is the header drawn over the bar, so the bar can never be narrower. + const minBarWidth = "CACHE HIT".length; + const maxBarWidth = 20; + const minProviderCol = 12; + const minModelCol = 10; + const minClientCol = 6; // Remote mode: `Alice (claude-code)` — owner display name + the client id // with the `-` suffix stripped. Local mode (no owner/name data) @@ -602,39 +699,81 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam hasOwnerInfo ? resolveClientLabel(entry.client, naming) : entry.client || "unknown" ); const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6); - const clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14; - // Budget: time + model + tokens + cost + provider + client + 5 separators. + // Token counts are drawn as `IN - OUT` with IN pinned to the left edge of the + // column and OUT to the right, so both halves need the widest value on show. + const tokenRows = recentEntries.map((entry) => tokenSegments(entry)); + const inputTexts = tokenRows.map((row) => formatNumber(row.input)); + const outputTexts = tokenRows.map((row) => formatNumber(row.output)); + const inputCol = Math.max(...inputTexts.map((text) => text.length)); + const outputCol = Math.max(...outputTexts.map((text) => text.length)); + const inOutCol = inputCol + outputCol + " - ".length; + + // Lay out the columns against the box's inner width: start from the widest + // layout, hand the slack to the provider column, then give space back in + // priority order (bar, provider, model, client) until everything fits. const innerWidth = Math.max(0, width - 4); - const providerCol = Math.max(12, innerWidth - timeCol - modelCol - tokensCol - costCol - 5 - clientCol); + let modelCol = 18; + let clientCol = hasOwnerInfo ? Math.min(32, Math.max(14, maxLabelLen)) : 14; + let barWidth = maxBarWidth; + let showProvider = false; + let providerCol = 0; + // Column widths plus one separator between each visible column. + const usedWidth = () => + timeCol + modelCol + (barWidth + 1 + inOutCol) + costCol + clientCol + + (showProvider ? providerCol + 1 : 0) + 4; + + if (innerWidth - usedWidth() > minProviderCol + 1) { + showProvider = true; + providerCol = innerWidth - usedWidth(); + } + while (usedWidth() > innerWidth && barWidth > minBarWidth) barWidth -= 1; + while (usedWidth() > innerWidth && providerCol > minProviderCol) providerCol -= 1; + while (usedWidth() > innerWidth && modelCol > minModelCol) modelCol -= 1; + while (usedWidth() > innerWidth && clientCol > minClientCol) clientCol -= 1; + + const tokensCol = barWidth + 1 + inOutCol; - const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0; const lines: string[] = []; - lines.push(`${COLORS.bold}${["TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), "I/CR/CW/O".padEnd(tokensCol), "I/O/T in sats".padEnd(costCol), "BASE:PROVIDER".padEnd(providerCol), "CLIENT".padEnd(clientCol)].join(" ")}${COLORS.reset}`); - lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset); + const header = [ + "TIME".padEnd(timeCol), + "MODEL".padEnd(modelCol), + "CACHE HIT".padEnd(tokensCol - inOutCol) + "IN".padEnd(inputCol) + " - " + "OUT".padStart(outputCol), + // Right-aligned like the values below it, so the column's right edge is + // shared by the header and every `n sats` cell. + "COST".padStart(costCol), + ...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []), + "CLIENT".padEnd(clientCol), + ]; + lines.push(`${COLORS.bold}${header.join(" ")}${COLORS.reset}`); + lines.push(COLORS.dim + "─".repeat(innerWidth) + COLORS.reset); for (let i = 0; i < recentEntries.length; i++) { const entry = recentEntries[i]!; const time = formatTime(entry.timestamp).slice(0, 8); const model = entry.modelId.slice(0, modelCol).padEnd(modelCol); - const tokens = [ - entry.promptTokens, - entry.cacheReadInputTokens || 0, - entry.cacheCreationInputTokens || 0, - entry.completionTokens, - ].map(formatNumber).join("/"); + const segments = tokenRows[i]!; + const bar = renderStackedBar([ + { value: segments.cacheRead, color: TOKEN_BAR_COLORS.cacheRead }, + { value: segments.notCached, color: TOKEN_BAR_COLORS.notCached }, + ], barWidth); + const tokens = `${bar} ${inputTexts[i]!.padEnd(inputCol)} - ${outputTexts[i]!.padStart(outputCol)}`; const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost; - const cost = [ - formatCost(msatsToSats(entry.inputMsats)), - formatCost(msatsToSats(entry.outputMsats)), - formatCost(totalSats), - ].join("/"); + // Right-aligned so the `sats` unit ends at the same column on every row. + const cost = `${formatCost(totalSats)} sats`.padStart(costCol); const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", ""); const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol); const clientLabel = clientLabels[i]!.slice(0, clientCol).padEnd(clientCol); const clientColor = CLIENT_COLORS[entry.client || "unknown"] || CLIENT_COLORS.default || COLORS.white; const modelColor = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default; - lines.push(`${COLORS.dim}${time}${COLORS.reset} ${modelColor}${model}${COLORS.reset} ${tokens.padEnd(tokensCol)} ${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset} ${clientColor}${clientLabel}${COLORS.reset}`); + lines.push([ + `${COLORS.dim}${time}${COLORS.reset}`, + `${modelColor}${model}${COLORS.reset}`, + tokens, + `${COLORS.green}${cost.padEnd(costCol)}${COLORS.reset}`, + ...(showProvider ? [`${COLORS.dim}${provider}${COLORS.reset}`] : []), + `${clientColor}${clientLabel}${COLORS.reset}`, + ].join(" ")); } return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);