From cc54b874fec274abbdff7ebe30a4b6265ca5da06 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:46:13 +0200 Subject: [PATCH 1/6] feat(tui): stacked token bars in the Recent tab The Recent tab listed the raw prompt/cache-read/cache-write/completion token counts, which made it hard to see how much of a request was served from cache. Replace the numeric column with a per-row stacked bar and the row's token total: - green cache read (served from the prompt cache) - red cache write (written to the cache for later) - yellow uncached input (neither read nor written) - blue output tokens The bar is a fixed-width composition track (8..20 cells): proportions are drawn with largest-remainder rounding so segments always fill the track exactly, and magnitude is reported as the total tokens beside the bar. A row with no token data renders an empty track, and a "bars:" legend above the rows names each colour. tokenSegments() normalises the two provider usage shapes: OpenAI-style `prompt_tokens` includes the cached subsets, Anthropic-style tokens come alongside, so the prompt is only split when it can contain the cached counts. The total is summed from the drawn segments rather than trusting `totalTokens`, which under-counts when a provider reports cache tokens separately. Column widths are now planned against the box width: the provider column is dropped and the bar, model and client columns shrink as needed, so the table stays aligned from 200 columns down to 70 (previously the bar-less table overflowed its border below ~110 columns). Numbers for each segment are no longer listed individually - the bar plus total replace them. --- src/tui/usage/render.test.ts | 86 ++++++++++++++++- src/tui/usage/render.ts | 180 +++++++++++++++++++++++++++++++---- 2 files changed, 249 insertions(+), 17 deletions(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 76a6d9f..4c86599 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,86 @@ 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 prompt tokens into cache read, cache write and fresh input", () => { + expect(tokenSegments({ promptTokens: 10_000, completionTokens: 2500, cacheReadInputTokens: 9000, cacheCreationInputTokens: 1000 })) + .toEqual({ fresh: 0, cacheRead: 9000, cacheWrite: 1000, output: 2500, total: 12_500 }); + }); + + test("treats the whole prompt as fresh input when the cached counts cannot be part of it", () => { + expect(tokenSegments({ promptTokens: 100, completionTokens: 20, cacheReadInputTokens: 5000 })) + .toEqual({ fresh: 100, cacheRead: 5000, cacheWrite: 0, output: 20, total: 5120 }); + }); + + test("tolerates entries without cache or token fields", () => { + expect(tokenSegments({})).toEqual({ fresh: 0, cacheRead: 0, cacheWrite: 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, + promptTokens: 10_000, + completionTokens: 2500, + totalTokens: 12_500, + cacheReadInputTokens: 9000, + cacheCreationInputTokens: 1000, + client: "pi-agent", + }, + ], + } as unknown as UsageStats; + + test("draws a coloured bar per row with the token total beside it", () => { + const out = renderRecent(stats, 120, buildClientNaming([], [])); + + // 9000 / 1000 / 2500 out of 12.5K on a 20-cell track -> 14/2/4 cells. + expect(out).toContain(COLORS.green + "█".repeat(14) + COLORS.red + "█".repeat(2) + COLORS.blue + "█".repeat(4) + COLORS.reset); + // Total tokens to the right of the bar, plus the colour legend. + expect(stripAnsi(out)).toContain("12.5K"); + expect(stripAnsi(out)).toContain("bars: █ uncached input █ cache read █ cache write █ output"); + }); + + 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..8668f3f 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -585,14 +585,107 @@ function truncateNpub(npub: string): string { return npub.slice(0, 10) + "…" + npub.slice(-6); } +/** + * Token composition of a single request, split the way the TUI colours it: + * uncached input (fresh), cache read, cache write and output. + */ +export interface TokenSegments { + fresh: number; + cacheRead: number; + cacheWrite: number; + output: number; + total: number; +} + +/** + * Split a usage entry into colour-coded token segments. + * + * 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 and the whole prompt is fresh input (Anthropic). `total` + * describes what the bar actually draws rather than trusting 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 fresh = prompt >= cached ? prompt - cached : prompt; + return { fresh, cacheRead, cacheWrite, output, total: fresh + cacheRead + cacheWrite + 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 shared by the Recent tab's token bars and its legend. */ +const TOKEN_BAR_COLORS = { + fresh: COLORS.yellow, + cacheRead: COLORS.green, + cacheWrite: COLORS.red, + output: COLORS.blue, +}; + 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 = 15; + // Width reserved right of each token bar for the row's total token count. + const totalCol = 7; + const minBarWidth = 8; + 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,27 +695,75 @@ 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. + // 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 + totalCol) + 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 + totalCol; 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), + `${"TOKENS".padEnd(tokensCol - totalCol)}${"TOTAL".padStart(totalCol)}`, + "I/O/T in sats".padEnd(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); + + // Legend for the stacked bars, dropping entries that don't fit the width. + const legendPrefix = "bars: "; + const legendParts: string[] = []; + let legendLen = legendPrefix.length; + for (const [color, label] of [ + [TOKEN_BAR_COLORS.fresh, "uncached input"], + [TOKEN_BAR_COLORS.cacheRead, "cache read"], + [TOKEN_BAR_COLORS.cacheWrite, "cache write"], + [TOKEN_BAR_COLORS.output, "output"], + ] as Array<[string, string]>) { + // Joiner + "█ " + label. + const partLen = (legendParts.length > 0 ? 2 : 0) + label.length + 2; + if (legendLen + partLen > innerWidth) break; + legendParts.push(`${color}█${COLORS.reset}${COLORS.dim} ${label}${COLORS.reset}`); + legendLen += partLen; + } + lines.push(`${COLORS.dim}${legendPrefix}${COLORS.reset}${legendParts.join(" ")}`); 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 = tokenSegments(entry); + const bar = renderStackedBar([ + { value: segments.fresh, color: TOKEN_BAR_COLORS.fresh }, + { value: segments.cacheRead, color: TOKEN_BAR_COLORS.cacheRead }, + { value: segments.cacheWrite, color: TOKEN_BAR_COLORS.cacheWrite }, + { value: segments.output, color: TOKEN_BAR_COLORS.output }, + ], barWidth); + const tokens = `${bar} ${formatNumber(segments.total).padStart(totalCol)}`; const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost; const cost = [ formatCost(msatsToSats(entry.inputMsats)), @@ -634,7 +775,14 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam 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)`); From 2806cf86d291ada36e94e0085db5a6b8828e06ab Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:54:20 +0200 Subject: [PATCH 2/6] refactor(tui): two-colour token bar with input/output totals Cache writes and uncached input are both "input we had to pay full price for", so they collapse into a single red segment next to cache reads. The blue output segment is gone: output is not part of the cache story, so it is reported as a number instead. Each row now shows `/` to the right of the bar, where input is the full input token count (cache read + cache write + uncached) and output is the completion token count. Previously only the input total was implied by the bar and the single right-hand number. tokenSegments() returns { cacheRead, notCached, input, output, total } accordingly, and the legend shrinks to two entries. --- src/tui/usage/render.test.ts | 30 ++++++++++-------- src/tui/usage/render.ts | 59 +++++++++++++++++++----------------- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 4c86599..08059a7 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -170,18 +170,23 @@ describe("renderStackedBar", () => { }); describe("tokenSegments", () => { - test("splits prompt tokens into cache read, cache write and fresh input", () => { + 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({ fresh: 0, cacheRead: 9000, cacheWrite: 1000, output: 2500, total: 12_500 }); + .toEqual({ cacheRead: 9000, notCached: 1000, input: 10_000, output: 2500, total: 12_500 }); }); - test("treats the whole prompt as fresh input when the cached counts cannot be part of it", () => { - expect(tokenSegments({ promptTokens: 100, completionTokens: 20, cacheReadInputTokens: 5000 })) - .toEqual({ fresh: 100, cacheRead: 5000, cacheWrite: 0, output: 20, total: 5120 }); + 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({ fresh: 0, cacheRead: 0, cacheWrite: 0, output: 0, total: 0 }); + expect(tokenSegments({})).toEqual({ cacheRead: 0, notCached: 0, input: 0, output: 0, total: 0 }); }); }); @@ -208,14 +213,15 @@ describe("renderRecent token bars", () => { ], } as unknown as UsageStats; - test("draws a coloured bar per row with the token total beside it", () => { + test("draws a green/red bar per row with input/output tokens beside it", () => { const out = renderRecent(stats, 120, buildClientNaming([], [])); - // 9000 / 1000 / 2500 out of 12.5K on a 20-cell track -> 14/2/4 cells. - expect(out).toContain(COLORS.green + "█".repeat(14) + COLORS.red + "█".repeat(2) + COLORS.blue + "█".repeat(4) + COLORS.reset); - // Total tokens to the right of the bar, plus the colour legend. - expect(stripAnsi(out)).toContain("12.5K"); - expect(stripAnsi(out)).toContain("bars: █ uncached input █ cache read █ cache write █ output"); + // 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 + "█"); + expect(stripAnsi(out)).toContain("10.0K/2.5K"); + expect(stripAnsi(out)).toContain("bars: █ cache read █ input (cache write + uncached)"); }); 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 8668f3f..65167f6 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -586,27 +586,31 @@ function truncateNpub(npub: string): string { } /** - * Token composition of a single request, split the way the TUI colours it: - * uncached input (fresh), cache read, cache write and output. + * 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 { - fresh: number; + /** Input tokens served from the prompt cache. */ cacheRead: number; - cacheWrite: 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 colour-coded token segments. + * 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 and the whole prompt is fresh input (Anthropic). `total` - * describes what the bar actually draws rather than trusting a possibly - * under-counted `totalTokens`. + * 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; @@ -619,8 +623,10 @@ export function tokenSegments(entry: { const prompt = Math.max(0, entry.promptTokens || 0); const output = Math.max(0, entry.completionTokens || 0); const cached = cacheRead + cacheWrite; - const fresh = prompt >= cached ? prompt - cached : prompt; - return { fresh, cacheRead, cacheWrite, output, total: fresh + cacheRead + cacheWrite + output }; + 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}. */ @@ -667,20 +673,24 @@ export function renderStackedBar(segments: BarSegment[], trackWidth: number): st /** Colour coding shared by the Recent tab's token bars and its legend. */ const TOKEN_BAR_COLORS = { - fresh: COLORS.yellow, cacheRead: COLORS.green, - cacheWrite: COLORS.red, - output: COLORS.blue, + notCached: COLORS.red, }; +/** Legend labels, in bar order: cache read (green) then everything else (red). */ +const TOKEN_BAR_LEGEND: Array<[string, string]> = [ + [TOKEN_BAR_COLORS.cacheRead, "cache read"], + [TOKEN_BAR_COLORS.notCached, "input (cache write + uncached)"], +]; + 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 = 8; const costCol = 15; - // Width reserved right of each token bar for the row's total token count. - const totalCol = 7; + // Width reserved right of each token bar for `input/output` token counts. + const inOutCol = 13; const minBarWidth = 8; const maxBarWidth = 20; const minProviderCol = 12; @@ -707,7 +717,7 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam let providerCol = 0; // Column widths plus one separator between each visible column. const usedWidth = () => - timeCol + modelCol + (barWidth + 1 + totalCol) + costCol + clientCol + + timeCol + modelCol + (barWidth + 1 + inOutCol) + costCol + clientCol + (showProvider ? providerCol + 1 : 0) + 4; if (innerWidth - usedWidth() > minProviderCol + 1) { @@ -719,14 +729,14 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam while (usedWidth() > innerWidth && modelCol > minModelCol) modelCol -= 1; while (usedWidth() > innerWidth && clientCol > minClientCol) clientCol -= 1; - const tokensCol = barWidth + 1 + totalCol; + const tokensCol = barWidth + 1 + inOutCol; const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0; const lines: string[] = []; const header = [ "TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), - `${"TOKENS".padEnd(tokensCol - totalCol)}${"TOTAL".padStart(totalCol)}`, + `${"TOKENS".padEnd(tokensCol - inOutCol)}${"IN/OUT".padStart(inOutCol)}`, "I/O/T in sats".padEnd(costCol), ...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []), "CLIENT".padEnd(clientCol), @@ -738,12 +748,7 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam const legendPrefix = "bars: "; const legendParts: string[] = []; let legendLen = legendPrefix.length; - for (const [color, label] of [ - [TOKEN_BAR_COLORS.fresh, "uncached input"], - [TOKEN_BAR_COLORS.cacheRead, "cache read"], - [TOKEN_BAR_COLORS.cacheWrite, "cache write"], - [TOKEN_BAR_COLORS.output, "output"], - ] as Array<[string, string]>) { + for (const [color, label] of TOKEN_BAR_LEGEND) { // Joiner + "█ " + label. const partLen = (legendParts.length > 0 ? 2 : 0) + label.length + 2; if (legendLen + partLen > innerWidth) break; @@ -758,12 +763,10 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam const model = entry.modelId.slice(0, modelCol).padEnd(modelCol); const segments = tokenSegments(entry); const bar = renderStackedBar([ - { value: segments.fresh, color: TOKEN_BAR_COLORS.fresh }, { value: segments.cacheRead, color: TOKEN_BAR_COLORS.cacheRead }, - { value: segments.cacheWrite, color: TOKEN_BAR_COLORS.cacheWrite }, - { value: segments.output, color: TOKEN_BAR_COLORS.output }, + { value: segments.notCached, color: TOKEN_BAR_COLORS.notCached }, ], barWidth); - const tokens = `${bar} ${formatNumber(segments.total).padStart(totalCol)}`; + const tokens = `${bar} ${`${formatNumber(segments.input)}/${formatNumber(segments.output)}`.padStart(inOutCol)}`; const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost; const cost = [ formatCost(msatsToSats(entry.inputMsats)), From 651bacee997dfb36ca2613a83633dc52bfb3744d Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:58:54 +0200 Subject: [PATCH 3/6] refactor(tui): show only the total sats cost in the Recent tab The cost column listed input/output/total sats per request (`1.00/2.00/3.00`). The split is not something you act on while scanning recent requests, so show just the request's total sats cost under a `TOTAL SATS` header. inputMsats/outputMsats are still recorded on each usage entry - they are just no longer rendered. Dropping the breakdown also frees 4 columns, which the provider column absorbs. --- src/tui/usage/render.test.ts | 12 ++++++++++++ src/tui/usage/render.ts | 11 +++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 08059a7..0e59446 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -203,6 +203,9 @@ describe("renderRecent token bars", () => { requestId: "r1", cost: 0, satsCost: 1, + inputMsats: 1000, + outputMsats: 2000, + totalMsats: 3000, promptTokens: 10_000, completionTokens: 2500, totalTokens: 12_500, @@ -224,6 +227,15 @@ describe("renderRecent token bars", () => { expect(stripAnsi(out)).toContain("bars: █ cache read █ input (cache write + uncached)"); }); + test("shows only the total sats cost, not the input/output breakdown", () => { + const out = stripAnsi(renderRecent(stats, 120, buildClientNaming([], []))); + + expect(out).toContain("TOTAL SATS"); + // 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"); + }); + 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"); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 65167f6..de0b2d4 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -688,7 +688,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 = 15; + const costCol = 11; // Width reserved right of each token bar for `input/output` token counts. const inOutCol = 13; const minBarWidth = 8; @@ -731,13 +731,12 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam const tokensCol = barWidth + 1 + inOutCol; - const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0; const lines: string[] = []; const header = [ "TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), `${"TOKENS".padEnd(tokensCol - inOutCol)}${"IN/OUT".padStart(inOutCol)}`, - "I/O/T in sats".padEnd(costCol), + "TOTAL SATS".padEnd(costCol), ...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []), "CLIENT".padEnd(clientCol), ]; @@ -768,11 +767,7 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam ], barWidth); const tokens = `${bar} ${`${formatNumber(segments.input)}/${formatNumber(segments.output)}`.padStart(inOutCol)}`; const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost; - const cost = [ - formatCost(msatsToSats(entry.inputMsats)), - formatCost(msatsToSats(entry.outputMsats)), - formatCost(totalSats), - ].join("/"); + const cost = formatCost(totalSats); 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); From c86eb1b186179668c25a0248fbd627bbdb522e48 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:01:20 +0200 Subject: [PATCH 4/6] feat(tui): name the Recent token bar CACHE HIT and drop the legend The bar reads as "input served from the cache" (green) vs "input that missed" (red), so name the column for that instead of TOKENS, and drop the `bars:` legend line - it cost a whole row and mostly restated the header. The bar's minimum width is now the header width, so `CACHE HIT` always fits above the track (previously the minimum was 8 cells, which would have let a 9-character header overflow its own column on narrow terminals). --- src/tui/usage/render.test.ts | 4 +++- src/tui/usage/render.ts | 27 +++++---------------------- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 0e59446..c2fd9bf 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -224,7 +224,9 @@ describe("renderRecent token bars", () => { // 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"); - expect(stripAnsi(out)).toContain("bars: █ cache read █ input (cache write + uncached)"); + // 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", () => { diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index de0b2d4..2e7211d 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -671,18 +671,12 @@ export function renderStackedBar(segments: BarSegment[], trackWidth: number): st return bar.length > 0 ? bar + COLORS.reset : " ".repeat(track); } -/** Colour coding shared by the Recent tab's token bars and its legend. */ +/** Colour coding for the Recent tab's cache-hit bars. */ const TOKEN_BAR_COLORS = { cacheRead: COLORS.green, notCached: COLORS.red, }; -/** Legend labels, in bar order: cache read (green) then everything else (red). */ -const TOKEN_BAR_LEGEND: Array<[string, string]> = [ - [TOKEN_BAR_COLORS.cacheRead, "cache read"], - [TOKEN_BAR_COLORS.notCached, "input (cache write + uncached)"], -]; - 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"); @@ -691,7 +685,9 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam const costCol = 11; // Width reserved right of each token bar for `input/output` token counts. const inOutCol = 13; - const minBarWidth = 8; + // 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; @@ -735,7 +731,7 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam const header = [ "TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), - `${"TOKENS".padEnd(tokensCol - inOutCol)}${"IN/OUT".padStart(inOutCol)}`, + `${"CACHE HIT".padEnd(tokensCol - inOutCol)}${"IN/OUT".padStart(inOutCol)}`, "TOTAL SATS".padEnd(costCol), ...(showProvider ? ["BASE:PROVIDER".padEnd(providerCol)] : []), "CLIENT".padEnd(clientCol), @@ -743,19 +739,6 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam lines.push(`${COLORS.bold}${header.join(" ")}${COLORS.reset}`); lines.push(COLORS.dim + "─".repeat(innerWidth) + COLORS.reset); - // Legend for the stacked bars, dropping entries that don't fit the width. - const legendPrefix = "bars: "; - const legendParts: string[] = []; - let legendLen = legendPrefix.length; - for (const [color, label] of TOKEN_BAR_LEGEND) { - // Joiner + "█ " + label. - const partLen = (legendParts.length > 0 ? 2 : 0) + label.length + 2; - if (legendLen + partLen > innerWidth) break; - legendParts.push(`${color}█${COLORS.reset}${COLORS.dim} ${label}${COLORS.reset}`); - legendLen += partLen; - } - lines.push(`${COLORS.dim}${legendPrefix}${COLORS.reset}${legendParts.join(" ")}`); - for (let i = 0; i < recentEntries.length; i++) { const entry = recentEntries[i]!; const time = formatTime(entry.timestamp).slice(0, 8); 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 5/6] 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); From 3658207e44bcc32d96dcd429b7f8199e18925b4c Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 12 Sep 2026 18:09:21 +0200 Subject: [PATCH 6/6] fix(tui): right-align the COST header over its values The `n sats` cells were right-aligned but the header was padded on the right, so `COST` floated at the left edge of the column while the values sat against the right edge. Pad the header on the left instead so it shares the column's right edge with every value. Adds a positional assertion that the header and the value end at the same column, so the alignment can't regress silently. --- src/tui/usage/render.test.ts | 6 ++++++ src/tui/usage/render.ts | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 7493d22..1a1a864 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -239,6 +239,12 @@ describe("renderRecent token bars", () => { 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", () => { diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 0e6f567..c6fcae5 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -739,7 +739,9 @@ export function renderRecent(stats: UsageStats, width: number, naming: ClientNam "TIME".padEnd(timeCol), "MODEL".padEnd(modelCol), "CACHE HIT".padEnd(tokensCol - inOutCol) + "IN".padEnd(inputCol) + " - " + "OUT".padStart(outputCol), - "COST".padEnd(costCol), + // 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), ];