From c29a11df2fb589e2c493c1ff88ff1d7cde28bc62 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:04:49 +0200 Subject: [PATCH] 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. --- src/tui/usage/render.test.ts | 13 ++++++++----- src/tui/usage/render.ts | 24 ++++++++++++++++-------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index c2fd9bf..7493d22 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -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", () => { diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 2e7211d..0e6f567 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -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);