feat(tui): pin IN/OUT to opposite ends and label the cost column COST

`IN/OUT` read as one blurry ratio (`57.2K/898`). Draw the counts as
`IN - OUT` with IN pinned to the left edge of the column and OUT to the
right, so the two values never collide: the separator lands in a fixed
column and the output count right-aligns, even as the numbers change
width between rows.

Both halves are sized from the widest value on show, so the separator is
placed correctly without a hardcoded budget that a long output count
(`1.9K`, `24.3K`) could overrun.

The sats column is now `COST` and spells out its unit per row
(`3.00 sats`), right-aligned so the unit lines up; the column gained a
cell to fit the widest value the formatter can emit.
This commit is contained in:
redshift
2026-09-12 18:04:49 +02:00
parent c86eb1b186
commit c29a11df2f
2 changed files with 24 additions and 13 deletions
+8 -5
View File
@@ -223,19 +223,22 @@ describe("renderRecent token bars", () => {
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 + "█");
expect(stripAnsi(out)).toContain("10.0K/2.5K");
// 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 sats cost, not the input/output breakdown", () => {
test("shows only the total cost in sats, not the input/output breakdown", () => {
const out = stripAnsi(renderRecent(stats, 120, buildClientNaming([], [])));
expect(out).toContain("TOTAL SATS");
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");
expect(out).not.toContain("1.00/2.00");
expect(out).toContain("3.00 sats");
expect(out).not.toContain("1.00 sats");
expect(out).not.toContain("2.00 sats");
});
test("keeps every row the same visible width as the box", () => {
+16 -8
View File
@@ -682,9 +682,7 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam
if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
const timeCol = 8;
const costCol = 11;
// Width reserved right of each token bar for `input/output` token counts.
const inOutCol = 13;
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;
@@ -702,6 +700,15 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam
);
const maxLabelLen = clientLabels.reduce((max, label) => Math.max(max, label.length), 6);
// 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.
@@ -731,8 +738,8 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam
const header = [
"TIME".padEnd(timeCol),
"MODEL".padEnd(modelCol),
`${"CACHE HIT".padEnd(tokensCol - inOutCol)}${"IN/OUT".padStart(inOutCol)}`,
"TOTAL SATS".padEnd(costCol),
"CACHE HIT".padEnd(tokensCol - inOutCol) + "IN".padEnd(inputCol) + " - " + "OUT".padStart(outputCol),
"COST".padEnd(costCol),
...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []),
"CLIENT".padEnd(clientCol),
];
@@ -743,14 +750,15 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam
const entry = recentEntries[i]!;
const time = formatTime(entry.timestamp).slice(0, 8);
const model = entry.modelId.slice(0, modelCol).padEnd(modelCol);
const segments = tokenSegments(entry);
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} ${`${formatNumber(segments.input)}/${formatNumber(segments.output)}`.padStart(inOutCol)}`;
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(totalSats);
// 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);