mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
Merge pull request #103 from Routstr/feat/tui-token-bars
feat(tui): stacked token bars in the Recent tab
This commit is contained in:
@@ -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
@@ -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)`);
|
||||
|
||||
Reference in New Issue
Block a user