diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index 9f25b67..4b70255 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -1,6 +1,6 @@ import { getVisibleTabs } from "./constants.ts"; import type { Tab } from "./types.ts"; -import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts"; +import { fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type NpubEntry, type StatusInfo } from "./data.ts"; import { applyScrollToContent, exitSearchMode, @@ -48,6 +48,7 @@ export async function runUsageTui(): Promise { let balance: BalanceInfo | null = null; let status: StatusInfo | null = null; let clients: ClientInfo[] = []; + let npubs: NpubEntry[] = []; let visibleTabs: Tab[] = getVisibleTabs(false); let refreshInterval: ReturnType | null = null; let autoRefresh = true; @@ -144,6 +145,14 @@ export async function runUsageTui(): Promise { currentTab = "clients"; vimState.scrollPos = 0; } + + // Names/roles live on the auth proxy, not the usage summary. Only + // fetch them when the Npubs tab is actually reachable, and keep the + // last good list if the endpoint is temporarily unreachable. + if (npubsVisible) { + const newNpubs = await fetchNpubs(); + if (newNpubs.length > 0) npubs = newNpubs; + } } render(); } finally { @@ -170,7 +179,7 @@ export async function runUsageTui(): Promise { return; } - const content = renderTabContent(currentTab, stats, balance, status, width, clients); + const content = renderTabContent(currentTab, stats, balance, status, width, npubs); const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`; const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar(); const chromeLines = chrome.split("\n").length - 1; diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts index e479cf5..99b6296 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -1,5 +1,6 @@ import type { UsageTrackingEntry } from "../../daemon/types.ts"; import { + callAuth, callDaemon, getDaemonBaseUrl, isDaemonRunning, @@ -145,3 +146,29 @@ export async function fetchClients(): Promise { export function hasAnyNpubs(clients: ClientInfo[]): boolean { return clients.some((c) => !!c.ownerNpub); } + +/** A configured npub as returned by the auth proxy (`/npubs`). */ +export interface NpubEntry { + npub: string; + name: string | null; + role: string; +} + +/** + * Fetch the configured npubs (with their display names and roles) from the + * auth proxy. Returns an empty list when the endpoint is unavailable — e.g. a + * local daemon that doesn't route `/npubs` — so the TUI degrades gracefully. + */ +export async function fetchNpubs(): Promise { + try { + const result = await callAuth("/npubs"); + if (result.error) return []; + + // Handle both wrapped { output: { npubs } } and direct { npubs } responses. + const direct = (result as { npubs?: NpubEntry[] }).npubs; + const wrapped = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs; + return direct ?? wrapped ?? []; + } catch { + return []; + } +} diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts new file mode 100644 index 0000000..b079b12 --- /dev/null +++ b/src/tui/usage/render.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { renderNpubs } from "./render.ts"; +import { stripAnsi } from "./terminal.ts"; +import type { NpubEntry } from "./data.ts"; +import type { UsageStats } from "./types.ts"; + +const NPUB = "npub1abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopq"; + +function statsFor(npub: string): UsageStats { + return { + entries: [], + totalEntries: 3, + totalSatsCost: 100, + recentSatsCost: 100, + limit: 50, + summary: { + npubs: [ + { + npub, + requests: 3, + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + cost: 0, + satsCost: 42, + topModels: [ + { + modelId: "gpt-5.4", + requests: 3, + satsCost: 42, + totalTokens: 30, + promptTokens: 10, + completionTokens: 20, + cost: 0, + } as never, + ], + }, + ], + } as never, + }; +} + +describe("renderNpubs", () => { + test("shows the configured display name and role", () => { + const npubs: NpubEntry[] = [{ npub: NPUB, name: "Alice", role: "admin" }]; + const out = stripAnsi(renderNpubs(statsFor(NPUB), npubs, 120)); + + expect(out).toContain("Alice"); + expect(out).toContain("[admin]"); + // Full npub stays visible for copy-ability. + expect(out).toContain(NPUB); + // Name is used in the top-models section too. + expect(out).toContain("Alice (3 reqs, 42.00 sats)"); + }); + + test("falls back to a truncated npub when no name is configured", () => { + const npubs: NpubEntry[] = [{ npub: NPUB, name: null, role: "user" }]; + const out = stripAnsi(renderNpubs(statsFor(NPUB), npubs, 120)); + + expect(out).toContain(NPUB.slice(0, 10) + "…" + NPUB.slice(-6)); + expect(out).toContain("[user]"); + }); + + test("renders when no npub metadata is available", () => { + const out = stripAnsi(renderNpubs(statsFor(NPUB), [], 120)); + expect(out).toContain("Npub Breakdown"); + }); +}); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index aebfcf8..8959684 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -3,7 +3,7 @@ import type { Tab } from "./types.ts"; import { formatNumber, formatTime, - type ClientInfo, + type NpubEntry, } from "./data.ts"; import { vimState } from "./state.ts"; import { stripAnsi } from "./terminal.ts"; @@ -503,20 +503,24 @@ export function renderClients(stats: UsageStats, width: number): string { return output; } -export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: number): string { +export function renderNpubs(stats: UsageStats, npubs: NpubEntry[], width: number): string { const npubStats = stats.summary.npubs; if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown"); + // Index configured npubs by their npub so usage rows can show names/roles. + const configured = new Map(); + for (const entry of npubs) configured.set(entry.npub, entry); + const totalCost = stats.totalSatsCost; const maxCost = npubStats[0]!.satsCost; const lines: string[] = []; - const col1 = 24; // Npub (truncated) + const col1 = 24; // Name (or truncated npub) const col2 = 12; // Requests const col3 = 24; // Cost const col4 = 12; // Tokens - const hNpub = "Npub".padEnd(col1); + const hNpub = "Name".padEnd(col1); const hReqs = "Requests".padEnd(col2); const hCost = "Cost".padEnd(col3); const hTok = "Tokens".padEnd(col4); @@ -527,9 +531,11 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num for (const npub of npubStats) { const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0"; const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 0); - const shortNpub = truncateNpub(npub.npub); + const entry = configured.get(npub.npub); + const name = entry?.name?.trim(); + const label = name || truncateNpub(npub.npub); - const dNpub = shortNpub.padEnd(col1); + const dNpub = label.slice(0, col1 - 1).padEnd(col1); const dReqs = formatReqs(npub.requests).padEnd(col2); const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3); const dTok = formatNumber(npub.totalTokens).padEnd(col4); @@ -541,8 +547,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num `${COLORS.green}${dCost}${COLORS.reset}` + `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}` ); - // Full npub on its own line for copy-ability - lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`); + // Full npub (and role when known) on its own line for copy-ability. + const roleSuffix = entry?.role ? ` [${entry.role}]` : ""; + lines.push(` ${COLORS.dim}${npub.npub}${roleSuffix}${COLORS.reset}`); lines.push(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`); lines.push(""); } @@ -555,7 +562,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num // Use pre-aggregated topModels from summary for (const topNpub of stats.summary.npubs.slice(0, 5)) { if (topNpub.topModels.length === 0) continue; - npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); + const name = configured.get(topNpub.npub)?.name?.trim(); + const label = name || truncateNpub(topNpub.npub); + npubModelLines.push(`${COLORS.bold}${label}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`); for (const m of topNpub.topModels) { npubModelLines.push(` ${(MODEL_COLORS[m.modelId] || MODEL_COLORS.default)}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`); } @@ -615,7 +624,7 @@ export function renderRecent(stats: UsageStats, width: number): string { return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`); } -export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, clients: ClientInfo[] = []): string { +export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, npubs: NpubEntry[] = []): string { switch (activeTab) { case "overview": return renderOverview(stats, balance, status, width); case "today": return renderToday(stats, width); @@ -623,7 +632,7 @@ export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: B case "providers": return renderProviders(stats, width); case "tokens": return renderTokens(stats, width); case "clients": return renderClients(stats, width); - case "npubs": return renderNpubs(stats, clients, width); + case "npubs": return renderNpubs(stats, npubs, width); case "recent": return renderRecent(stats, width); default: return "Unknown tab"; }