From 65d30c135bccd78490af72df16c26ea288721d22 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:49:46 +0000 Subject: [PATCH] format numbers with k/m suffixes, left-align bar chart labels --- src/tui/usage/render.ts | 114 +++++++++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 32 deletions(-) diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 943bb3c..0ea05a8 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -15,6 +15,20 @@ import { vimState } from "./state.ts"; import { stripAnsi } from "./terminal.ts"; import type { TabId, UsageStats } from "./types.ts"; +/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */ +function formatCost(value: number): string { + if (value >= 1_000_000) return (value / 1_000_000).toFixed(2) + "m"; + if (value >= 1_000) return (value / 1_000).toFixed(2) + "k"; + return value.toFixed(2); +} + +/** Format request count: 1, 12, 123, 1.2k, 1.2m */ +function formatReqs(value: number): string { + if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + "m"; + if (value >= 1_000) return (value / 1_000).toFixed(1) + "k"; + return value.toString(); +} + export function renderHeader(activeTab: TabId, width: number): string { const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`; const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`; @@ -57,6 +71,16 @@ export function renderBox(lines: string[], width: number, title?: string): strin return result.join("\n"); } +const _sectionMaxLabelLen = new Map(); + +export function startBarSection(sectionKey: string, maxLabelLen: number): void { + _sectionMaxLabelLen.set(sectionKey, maxLabelLen); +} + +export function endBarSection(_sectionKey: string): void { + // kept for API compat; no-op since we compute max per call now +} + export function renderBarChart( label: string, value: number, @@ -64,21 +88,24 @@ export function renderBarChart( width: number, color: string, percentageValue?: number, + sectionKey?: string, ): string { const safeMaxValue = Math.max(maxValue, 1); - const pctSource = percentageValue ?? value; - const pctDenominator = percentageValue !== undefined ? 100 : safeMaxValue; const pct = percentageValue !== undefined ? percentageValue.toFixed(1) - : ((pctSource / pctDenominator) * 100).toFixed(1); + : ((value / safeMaxValue) * 100).toFixed(1); const suffix = ` ${pct}%`; + + const maxLen = sectionKey ? (_sectionMaxLabelLen.get(sectionKey) ?? label.length) : label.length; + const paddedLabel = label.padEnd(maxLen); const reserved = suffix.length + 1; - const maxBarWidth = Math.max(0, width - label.length - reserved); + const maxBarWidth = Math.max(0, width - paddedLabel.length - reserved); const barLen = Math.max(0, Math.round((value / safeMaxValue) * maxBarWidth)); const bar = color + "█".repeat(barLen) + COLORS.reset; - return `${label}${bar}${suffix}`; + return `${paddedLabel} ${bar}${suffix}`; } + export function renderOverview(stats: UsageStats, width: number): string { const totals = getTotals(stats.entries); const entryCount = stats.entries.length; @@ -88,9 +115,9 @@ export function renderOverview(stats: UsageStats, width: number): string { const halfWidth = Math.floor((width - 6) / 2); const leftBox = [ - `${COLORS.bold}Total Spent:${COLORS.reset} ${COLORS.green}${totalVisibleCost.toFixed(3)} sats${COLORS.reset}`, - `${COLORS.bold}Total Requests:${COLORS.reset} ${formatNumber(entryCount)}`, - `${COLORS.bold}Avg Cost/Req:${COLORS.reset} ${avgCost.toFixed(3)} sats`, + `${COLORS.bold}Total Spent:${COLORS.reset} ${COLORS.green}${formatCost(totalVisibleCost)} sats${COLORS.reset}`, + `${COLORS.bold}Total Requests:${COLORS.reset} ${formatReqs(entryCount)}`, + `${COLORS.bold}Avg Cost/Req:${COLORS.reset} ${formatCost(avgCost)} sats`, ].map((l) => l.padEnd(halfWidth)); const rightBox = [ @@ -106,14 +133,18 @@ export function renderOverview(stats: UsageStats, width: number): string { if (modelStats.length > 0) { const maxCost = modelStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); + const maxModelLabel = Math.max(...modelStats.slice(0, 5).map((m) => m.modelId.length)) + 1; + startBarSection("models", maxModelLabel); const modelLines = modelStats.slice(0, 5).map((m) => renderBarChart( - `${m.modelId} `, + m.modelId + " ", m.satsCost, maxCost, width - 4, MODEL_COLORS[m.modelId] || MODEL_COLORS.default || COLORS.white, (m.satsCost / totalCost) * 100, + "models", )); + endBarSection("models"); output += "\n" + renderBox(modelLines, width, "Top Models by Cost"); } @@ -121,14 +152,18 @@ export function renderOverview(stats: UsageStats, width: number): string { if (clientStats.length > 0) { const maxCost = clientStats[0]!.satsCost; const totalCost = Math.max(totalVisibleCost, 1); + const maxClientLabel = Math.max(...clientStats.slice(0, 5).map((c) => c.client.length)) + 1; + startBarSection("clients", maxClientLabel); const clientLines = clientStats.slice(0, 5).map((c) => renderBarChart( - `${c.client} `, + c.client + " ", c.satsCost, maxCost, width - 4, CLIENT_COLORS[c.client] || CLIENT_COLORS.default || COLORS.white, (c.satsCost / totalCost) * 100, + "clients", )); + endBarSection("clients"); output += "\n" + renderBox(clientLines, width, "Usage by Client"); } @@ -153,8 +188,8 @@ export function renderToday(stats: UsageStats, width: number): string { const summaryLines = [ `${COLORS.bold}Date:${COLORS.reset} ${todayStats.date}`, - `${COLORS.bold}Requests:${COLORS.reset} ${todayStats.requests}`, - `${COLORS.bold}Cost:${COLORS.reset} ${COLORS.green}${todayStats.satsCost.toFixed(3)} sats${COLORS.reset}`, + `${COLORS.bold}Requests:${COLORS.reset} ${formatReqs(todayStats.requests)}`, + `${COLORS.bold}Cost:${COLORS.reset} ${COLORS.green}${formatCost(todayStats.satsCost)} sats${COLORS.reset}`, `${COLORS.bold}Tokens:${COLORS.reset} ${formatNumber(todayStats.totalTokens)} (p: ${formatNumber(todayStats.promptTokens)} + c: ${formatNumber(todayStats.completionTokens)})`, ]; @@ -164,27 +199,36 @@ export function renderToday(stats: UsageStats, width: number): string { const hourLines: string[] = []; const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1); const totalTodayCost = Math.max(todayStats.satsCost, 1); + const hourLabels: string[] = []; for (let h = 0; h <= currentHour; h++) { const hStat = hourly.get(h); const reqs = hStat?.requests || 0; const cost = hStat?.satsCost || 0; - const time = `${h.toString().padStart(2, "0")}:00`; - const label = `${time} (${reqs} req, ${cost.toFixed(2)} sats) `; + hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `); + } + const maxHourLabel = Math.max(...hourLabels.map((l) => l.length)); + startBarSection("hourly", maxHourLabel); + for (let i = 0; i <= currentHour; i++) { + const hStat = hourly.get(i); + const reqs = hStat?.requests || 0; + const cost = hStat?.satsCost || 0; hourLines.push(renderBarChart( - label, + hourLabels[i]!, cost, maxHourCost, width - 4, - h === currentHour ? COLORS.green : COLORS.cyan, + i === currentHour ? COLORS.green : COLORS.cyan, (cost / totalTodayCost) * 100, + "hourly", )); } + endBarSection("hourly"); output += "\n" + renderBox(hourLines.length > 0 ? hourLines : ["No activity today yet"], width); const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7); if (days.length > 1) { - const dayLines = days.slice(1).map((d) => `${d.date}: ${d.requests} req, ${d.satsCost.toFixed(2)} sats, ${formatNumber(d.totalTokens)} tokens`); + const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`); output += "\n" + renderBox(dayLines, width, "Recent Days"); } @@ -197,19 +241,22 @@ export function renderModels(stats: UsageStats, width: number): string { const totalCost = getTotals(stats.entries).satsCost; const maxCost = modelStats[0]!.satsCost; + const maxModelLabel = Math.max(...modelStats.map((m) => m.modelId.length)); const lines: string[] = []; + startBarSection("model-detail", maxModelLabel); for (const model of modelStats) { const color = MODEL_COLORS[model.modelId] || MODEL_COLORS.default || COLORS.white; const pct = totalCost > 0 ? ((model.satsCost / totalCost) * 100).toFixed(1) : "0.0"; lines.push(`${color}${COLORS.bold}${model.modelId}${COLORS.reset}`); - lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${model.satsCost.toFixed(3)} sats (${pct}%)`); - lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${model.requests}`); + lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(model.satsCost)} sats (${pct}%)`); + lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(model.requests)}`); lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(model.totalTokens)}`); - lines.push(` ${COLORS.dim}Avg:${COLORS.reset} ${(model.satsCost / model.requests).toFixed(4)} sats/req`); - lines.push(` ${renderBarChart(" ", model.satsCost, maxCost, width - 4, color, Number(pct))}`); + lines.push(` ${COLORS.dim}Avg:${COLORS.reset} ${formatCost(model.satsCost / model.requests)} sats/req`); + lines.push(` ${renderBarChart(" ", model.satsCost, maxCost, width - 4, color, Number(pct), "model-detail")}`); lines.push(""); } + endBarSection("model-detail"); return renderBox(lines, width, "Model Breakdown"); } @@ -222,8 +269,8 @@ export function renderProviders(stats: UsageStats, width: number): string { for (const provider of providerStats) { const shortUrl = provider.baseUrl.replace("https://", "").replace("http://", ""); lines.push(`${COLORS.cyan}${COLORS.bold}${shortUrl}${COLORS.reset}`); - lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${provider.requests}`); - lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${provider.satsCost.toFixed(3)} sats`); + lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(provider.requests)}`); + lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(provider.satsCost)} sats`); lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(provider.totalTokens)}`); lines.push(""); } @@ -270,7 +317,7 @@ export function renderTokens(stats: UsageStats, width: number): string { } } - const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${bucket.count.toString().padStart(4)} reqs, ${bucket.cost.toFixed(2)} sats`); + const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`); output += "\n" + renderBox(sizeLines, width, "Request Size Distribution"); return output; } @@ -281,23 +328,26 @@ export function renderClients(stats: UsageStats, width: number): string { const totalCost = getTotals(stats.entries).satsCost; const maxCost = clientStats[0]!.satsCost; + const maxClientLabel = Math.max(...clientStats.map((c) => c.client.length)); const lines: string[] = []; lines.push(`${COLORS.bold}Client${"".padEnd(20)} Requests${"".padEnd(10)} Cost${"".padEnd(12)} Tokens${"".padEnd(10)} Avg Cost${COLORS.reset}`); lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset); + startBarSection("client-detail", maxClientLabel); for (const client of clientStats) { const color = CLIENT_COLORS[client.client] || CLIENT_COLORS.default || COLORS.white; const pct = totalCost > 0 ? ((client.satsCost / totalCost) * 100).toFixed(1) : "0.0"; - const avgCost = client.requests > 0 ? (client.satsCost / client.requests).toFixed(4) : "0.0000"; + const avgCostFormatted = formatCost(client.requests > 0 ? client.satsCost / client.requests : 0); lines.push( `${color}${COLORS.bold}${client.client.padEnd(20)}${COLORS.reset}` + - `${client.requests.toString().padEnd(12)}` + - `${COLORS.green}${client.satsCost.toFixed(3).padEnd(12)} sats${COLORS.reset} (${pct}%)` + - `${COLORS.dim} ${formatNumber(client.totalTokens).padEnd(10)} ${avgCost.padEnd(10)} sats/req${COLORS.reset}` + `${formatReqs(client.requests).padEnd(12)}` + + `${COLORS.green}${formatCost(client.satsCost).padEnd(12)} sats${COLORS.reset} (${pct}%)` + + `${COLORS.dim} ${formatNumber(client.totalTokens).padEnd(10)} ${avgCostFormatted.padEnd(10)} sats/req${COLORS.reset}` ); - lines.push(` ${renderBarChart("", client.satsCost, maxCost, width - 4, color, Number(pct))}`); + lines.push(` ${renderBarChart("", client.satsCost, maxCost, width - 4, color, Number(pct), "client-detail")}`); lines.push(""); } + endBarSection("client-detail"); let output = renderBox(lines, width, "Client Breakdown"); const clientModelMap = new Map>(); @@ -320,9 +370,9 @@ export function renderClients(stats: UsageStats, width: number): string { const modelMap = clientModelMap.get(topClient.client); if (!modelMap) continue; const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5); - clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${topClient.requests} reqs, ${topClient.satsCost.toFixed(2)} sats)`); + clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`); for (const [model, data] of models) { - clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${data.satsCost.toFixed(3)} sats`); + clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`); } clientModelLines.push(""); } @@ -345,7 +395,7 @@ export function renderRecent(stats: UsageStats, width: number): string { const time = formatTime(entry.timestamp).slice(0, 8); const model = entry.modelId.slice(0, 18).padEnd(18); const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`; - const cost = `${entry.satsCost.toFixed(3).padEnd(10)} sats`; + const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`; const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 60)); const color = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default; lines.push(`${COLORS.dim}${time}${COLORS.reset} ${color}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);