Merge pull request #103 from Routstr/feat/tui-token-bars

feat(tui): stacked token bars in the Recent tab
This commit is contained in:
redshift
2026-09-12 19:04:41 +02:00
committed by GitHub
2 changed files with 275 additions and 23 deletions
+114 -1
View File
@@ -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));
}
});
});
+161 -22
View File
@@ -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 `-<npub tail>` 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)`);