feat(tui): show npub display names in the Npubs tab

Fetch configured npub names/roles from the auth proxy (`/npubs`) and use
them in the Npubs tab, falling back to the truncated npub when a name is
not set. The full npub stays on its own line, now with a role tag.

- data: add NpubEntry + fetchNpubs() (handles wrapped/direct responses,
  returns [] when /npubs is unavailable)
- app: fetch names only when the Npubs tab is visible; keep last good list
- render: name-first column, role tag, named "Top Models per Npub"
- add renderNpubs tests
This commit is contained in:
redshift
2026-09-11 07:23:09 +02:00
parent 1fe32bb9b8
commit dd4a673d5c
4 changed files with 126 additions and 13 deletions
+11 -2
View File
@@ -1,6 +1,6 @@
import { getVisibleTabs } from "./constants.ts"; import { getVisibleTabs } from "./constants.ts";
import type { Tab } from "./types.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 { import {
applyScrollToContent, applyScrollToContent,
exitSearchMode, exitSearchMode,
@@ -48,6 +48,7 @@ export async function runUsageTui(): Promise<void> {
let balance: BalanceInfo | null = null; let balance: BalanceInfo | null = null;
let status: StatusInfo | null = null; let status: StatusInfo | null = null;
let clients: ClientInfo[] = []; let clients: ClientInfo[] = [];
let npubs: NpubEntry[] = [];
let visibleTabs: Tab[] = getVisibleTabs(false); let visibleTabs: Tab[] = getVisibleTabs(false);
let refreshInterval: ReturnType<typeof setInterval> | null = null; let refreshInterval: ReturnType<typeof setInterval> | null = null;
let autoRefresh = true; let autoRefresh = true;
@@ -144,6 +145,14 @@ export async function runUsageTui(): Promise<void> {
currentTab = "clients"; currentTab = "clients";
vimState.scrollPos = 0; 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(); render();
} finally { } finally {
@@ -170,7 +179,7 @@ export async function runUsageTui(): Promise<void> {
return; 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 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 chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
const chromeLines = chrome.split("\n").length - 1; const chromeLines = chrome.split("\n").length - 1;
+27
View File
@@ -1,5 +1,6 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts"; import type { UsageTrackingEntry } from "../../daemon/types.ts";
import { import {
callAuth,
callDaemon, callDaemon,
getDaemonBaseUrl, getDaemonBaseUrl,
isDaemonRunning, isDaemonRunning,
@@ -145,3 +146,29 @@ export async function fetchClients(): Promise<ClientInfo[]> {
export function hasAnyNpubs(clients: ClientInfo[]): boolean { export function hasAnyNpubs(clients: ClientInfo[]): boolean {
return clients.some((c) => !!c.ownerNpub); 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<NpubEntry[]> {
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 [];
}
}
+68
View File
@@ -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");
});
});
+20 -11
View File
@@ -3,7 +3,7 @@ import type { Tab } from "./types.ts";
import { import {
formatNumber, formatNumber,
formatTime, formatTime,
type ClientInfo, type NpubEntry,
} from "./data.ts"; } from "./data.ts";
import { vimState } from "./state.ts"; import { vimState } from "./state.ts";
import { stripAnsi } from "./terminal.ts"; import { stripAnsi } from "./terminal.ts";
@@ -503,20 +503,24 @@ export function renderClients(stats: UsageStats, width: number): string {
return output; 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; const npubStats = stats.summary.npubs;
if (npubStats.length === 0) return renderBox(["No npub data available"], width, "Npub Breakdown"); 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<string, NpubEntry>();
for (const entry of npubs) configured.set(entry.npub, entry);
const totalCost = stats.totalSatsCost; const totalCost = stats.totalSatsCost;
const maxCost = npubStats[0]!.satsCost; const maxCost = npubStats[0]!.satsCost;
const lines: string[] = []; const lines: string[] = [];
const col1 = 24; // Npub (truncated) const col1 = 24; // Name (or truncated npub)
const col2 = 12; // Requests const col2 = 12; // Requests
const col3 = 24; // Cost const col3 = 24; // Cost
const col4 = 12; // Tokens const col4 = 12; // Tokens
const hNpub = "Npub".padEnd(col1); const hNpub = "Name".padEnd(col1);
const hReqs = "Requests".padEnd(col2); const hReqs = "Requests".padEnd(col2);
const hCost = "Cost".padEnd(col3); const hCost = "Cost".padEnd(col3);
const hTok = "Tokens".padEnd(col4); const hTok = "Tokens".padEnd(col4);
@@ -527,9 +531,11 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
for (const npub of npubStats) { for (const npub of npubStats) {
const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0"; const pct = totalCost > 0 ? ((npub.satsCost / totalCost) * 100).toFixed(1) : "0.0";
const avgCostFormatted = formatCost(npub.requests > 0 ? npub.satsCost / npub.requests : 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 dReqs = formatReqs(npub.requests).padEnd(col2);
const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3); const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
const dTok = formatNumber(npub.totalTokens).padEnd(col4); 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.green}${dCost}${COLORS.reset}` +
`${COLORS.dim}${dTok}${dAvg}${COLORS.reset}` `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
); );
// Full npub on its own line for copy-ability // Full npub (and role when known) on its own line for copy-ability.
lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`); 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(` ${renderBarChart("", npub.satsCost, maxCost, width - 6, COLORS.magenta, Number(pct), "npub-detail")}`);
lines.push(""); lines.push("");
} }
@@ -555,7 +562,9 @@ export function renderNpubs(stats: UsageStats, clients: ClientInfo[], width: num
// Use pre-aggregated topModels from summary // Use pre-aggregated topModels from summary
for (const topNpub of stats.summary.npubs.slice(0, 5)) { for (const topNpub of stats.summary.npubs.slice(0, 5)) {
if (topNpub.topModels.length === 0) continue; 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) { 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`); 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)`); 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) { switch (activeTab) {
case "overview": return renderOverview(stats, balance, status, width); case "overview": return renderOverview(stats, balance, status, width);
case "today": return renderToday(stats, 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 "providers": return renderProviders(stats, width);
case "tokens": return renderTokens(stats, width); case "tokens": return renderTokens(stats, width);
case "clients": return renderClients(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); case "recent": return renderRecent(stats, width);
default: return "Unknown tab"; default: return "Unknown tab";
} }