From e3601d5de764bdbffe03f13cd545a6c5adb1f9a4 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:31:01 +0200 Subject: [PATCH] feat(tui): show owner names next to client ids in the Recent tab The auth proxy folds the last 7 chars of the owner's npub into client ids (`claude-code-a1b2c3d`) and the daemon never strips it, so the Recent tab's 13-char client cell clipped exactly the part that identifies the owner. Resolve each usage entry's client id back to `Name (client-id)` by joining GET /clients (clientId -> ownerNpub) with GET /npubs (npub -> name) and stripping the `-` suffix. Local mode has neither source, so its rendering is unchanged (raw id in the previous 14-col cell). - data: NPUB_SUFFIX_LENGTH, ClientNaming, buildClientNaming(), resolveClientLabel(); suffix lookups derive from /npubs and /clients - render: thread ClientNaming through renderTabContent, size the Recent client column to the labels, let BASE:PROVIDER absorb the remainder - tests: label resolution and both renderers --- src/tui/usage/app.ts | 9 +++- src/tui/usage/data.ts | 83 +++++++++++++++++++++++++++++++++ src/tui/usage/render.test.ts | 89 +++++++++++++++++++++++++++++++++--- src/tui/usage/render.ts | 46 +++++++++++++------ 4 files changed, 203 insertions(+), 24 deletions(-) diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index 4b70255..ba1873f 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, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type NpubEntry, type StatusInfo } from "./data.ts"; +import { buildClientNaming, emptyClientNaming, fetchBalance, fetchClients, fetchNpubs, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type ClientNaming, type NpubEntry, type StatusInfo } from "./data.ts"; import { applyScrollToContent, exitSearchMode, @@ -49,6 +49,7 @@ export async function runUsageTui(): Promise { let status: StatusInfo | null = null; let clients: ClientInfo[] = []; let npubs: NpubEntry[] = []; + let naming: ClientNaming = emptyClientNaming(); let visibleTabs: Tab[] = getVisibleTabs(false); let refreshInterval: ReturnType | null = null; let autoRefresh = true; @@ -153,6 +154,10 @@ export async function runUsageTui(): Promise { const newNpubs = await fetchNpubs(); if (newNpubs.length > 0) npubs = newNpubs; } + + // Owner info (for "Name (client-id)" labels in the Recent tab) comes + // from /clients + /npubs; both are empty in local mode. + naming = buildClientNaming(clients, npubs); } render(); } finally { @@ -179,7 +184,7 @@ export async function runUsageTui(): Promise { return; } - const content = renderTabContent(currentTab, stats, balance, status, width, npubs); + const content = renderTabContent(currentTab, stats, balance, status, width, naming); 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 99b6296..8284f61 100644 --- a/src/tui/usage/data.ts +++ b/src/tui/usage/data.ts @@ -172,3 +172,86 @@ export async function fetchNpubs(): Promise { return []; } } + +/** Number of trailing npub characters the auth proxy folds into a client id. */ +export const NPUB_SUFFIX_LENGTH = 7; + +/** + * Lookup tables for turning a raw usage `client` id (e.g. + * `claude-code-a1b2c3d`) back into a human label (e.g. `Alice (claude-code)`). + * + * All maps are empty in local mode, so {@link resolveClientLabel} falls back to + * the raw id with no behaviour change there. + */ +export interface ClientNaming { + /** Configured npubs (names + roles) from the auth proxy. */ + npubs: NpubEntry[]; + /** bare clientId -> owner npub, from `GET /clients`. */ + ownersByClientId: Map; + /** npub -> display name (null when unset). */ + npubNames: Map; + /** trailing npub suffix -> npub. */ + npubsBySuffix: Map; +} + +export function buildClientNaming(clients: ClientInfo[], npubs: NpubEntry[]): ClientNaming { + const ownersByClientId = new Map(); + for (const c of clients) { + if (c.ownerNpub) ownersByClientId.set(c.clientId, c.ownerNpub); + } + + const npubNames = new Map(); + const npubsBySuffix = new Map(); + const remember = (npub: string) => npubsBySuffix.set(npub.slice(-NPUB_SUFFIX_LENGTH), npub); + + for (const n of npubs) { + npubNames.set(n.npub, n.name ?? null); + remember(n.npub); + } + // `GET /clients` also carries owner npubs, which covers entries whose owner + // isn't present in the (possibly filtered) `/npubs` response. + for (const owner of ownersByClientId.values()) remember(owner); + + return { npubs, ownersByClientId, npubNames, npubsBySuffix }; +} + +/** Configured display name for an npub, or null when unset/blank. */ +function npubDisplayName(naming: ClientNaming, npub: string): string | null { + const name = naming.npubNames.get(npub)?.trim(); + return name && name.length > 0 ? name : null; +} + +/** `Alice (claude-code)` when the owner has a name, else the bare client id. */ +function formatClientLabel(naming: ClientNaming, bareId: string, ownerNpub: string): string { + const name = npubDisplayName(naming, ownerNpub); + return name ? `${name} (${bareId})` : bareId; +} + +/** + * Render a usage entry's `client` value as `Name (client-id)`, where `Name` is + * the owner npub's display name and the `-` suffix is stripped. + * Falls back to the raw id when no owner/name can be resolved (local mode). + */ +export function resolveClientLabel(clientId: string | undefined, naming: ClientNaming): string { + const raw = clientId && clientId.length > 0 ? clientId : "unknown"; + + // Exact match: the raw id is a known bare clientId or its suffixed form. + for (const [bareId, ownerNpub] of naming.ownersByClientId) { + const suffixed = `${bareId}-${ownerNpub.slice(-NPUB_SUFFIX_LENGTH)}`; + if (raw !== bareId && raw !== suffixed) continue; + return formatClientLabel(naming, bareId, ownerNpub); + } + + // Fallback: strip a trailing `-` that matches a known npub. + for (const [suffix, npub] of naming.npubsBySuffix) { + const marker = `-${suffix}`; + if (raw.length <= marker.length || !raw.endsWith(marker)) continue; + return formatClientLabel(naming, raw.slice(0, -marker.length), npub); + } + + return raw; +} + +export function emptyClientNaming(): ClientNaming { + return buildClientNaming([], []); +} diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index b079b12..76a6d9f 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { renderNpubs } from "./render.ts"; +import { renderNpubs, renderRecent } from "./render.ts"; import { stripAnsi } from "./terminal.ts"; -import type { NpubEntry } from "./data.ts"; +import { buildClientNaming, resolveClientLabel, type ClientInfo, type NpubEntry } from "./data.ts"; import type { UsageStats } from "./types.ts"; const NPUB = "npub1abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopq"; @@ -42,8 +42,8 @@ function statsFor(npub: string): UsageStats { 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)); + const naming = buildClientNaming([], [{ npub: NPUB, name: "Alice", role: "admin" }]); + const out = stripAnsi(renderNpubs(statsFor(NPUB), naming, 120)); expect(out).toContain("Alice"); expect(out).toContain("[admin]"); @@ -54,15 +54,90 @@ describe("renderNpubs", () => { }); 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)); + const naming = buildClientNaming([], [{ npub: NPUB, name: null, role: "user" }]); + const out = stripAnsi(renderNpubs(statsFor(NPUB), naming, 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)); + const out = stripAnsi(renderNpubs(statsFor(NPUB), buildClientNaming([], []), 120)); expect(out).toContain("Npub Breakdown"); }); }); + +describe("resolveClientLabel", () => { + const client: ClientInfo = { clientId: "claude-code", name: "Claude Code", ownerNpub: NPUB }; + const withName = buildClientNaming([client], [{ npub: NPUB, name: "Alice", role: "admin" }]); + + test("shows owner name and strips the npub suffix", () => { + expect(resolveClientLabel(`claude-code-${NPUB.slice(-7)}`, withName)).toBe("Alice (claude-code)"); + }); + + test("falls back to the bare client id when the owner has no name", () => { + const unnamed = buildClientNaming([client], [{ npub: NPUB, name: null, role: "user" }]); + expect(resolveClientLabel(`claude-code-${NPUB.slice(-7)}`, unnamed)).toBe("claude-code"); + }); + + test("leaves unsuffixed ids and local mode untouched", () => { + expect(resolveClientLabel("claude-code", withName)).toBe("Alice (claude-code)"); + expect(resolveClientLabel("pi-agent", buildClientNaming([], []))).toBe("pi-agent"); + }); +}); + +describe("renderRecent", () => { + test("renders Name (bare-id) with the owner suffix stripped", () => { + const naming = buildClientNaming( + [{ clientId: "claude-code", name: "Claude Code", ownerNpub: NPUB }], + [{ npub: NPUB, name: "Alice", role: "admin" }], + ); + 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, + completionTokens: 20, + totalTokens: 30, + client: `claude-code-${NPUB.slice(-7)}`, + }, + ], + } as unknown as UsageStats; + + const out = stripAnsi(renderRecent(stats, 120, naming)); + expect(out).toContain("Alice (claude-code)"); + expect(out).not.toContain(NPUB.slice(-7)); + }); + + test("keeps the raw client id when there is no owner metadata", () => { + const stats = { + ...statsFor(NPUB), + entries: [ + { + id: "1", + timestamp: Date.now(), + modelId: "gpt-5.4", + baseUrl: "https://api.example.com", + requestId: "r1", + cost: 0, + satsCost: 1, + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + client: "pi-agent", + }, + ], + } as unknown as UsageStats; + + const out = stripAnsi(renderRecent(stats, 120, buildClientNaming([], []))); + expect(out).toContain("pi-agent"); + }); +}); diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 8959684..5c992fb 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -3,7 +3,8 @@ import type { Tab } from "./types.ts"; import { formatNumber, formatTime, - type NpubEntry, + resolveClientLabel, + type ClientNaming, } from "./data.ts"; import { vimState } from "./state.ts"; import { stripAnsi } from "./terminal.ts"; @@ -503,13 +504,12 @@ export function renderClients(stats: UsageStats, width: number): string { return output; } -export function renderNpubs(stats: UsageStats, npubs: NpubEntry[], width: number): string { +export function renderNpubs(stats: UsageStats, naming: ClientNaming, 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 configured = new Map(naming.npubs.map((entry) => [entry.npub, entry])); const totalCost = stats.totalSatsCost; const maxCost = npubStats[0]!.satsCost; @@ -585,22 +585,38 @@ function truncateNpub(npub: string): string { return npub.slice(0, 10) + "…" + npub.slice(-6); } -export function renderRecent(stats: UsageStats, width: number): string { +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 clientCol = 14; + const timeCol = 10; + const modelCol = 18; const tokensCol = 18; const costCol = 18; - const providerCol = Math.max(16, width - 4 - 10 - 18 - tokensCol - costCol - clientCol - 5); + + // Remote mode: `Alice (claude-code)` — owner display name + the client id + // with the `-` suffix stripped. Local mode (no owner/name data) + // keeps the previous behaviour: the raw id in a fixed 14-col cell. + const hasOwnerInfo = naming.npubs.length > 0 || naming.ownersByClientId.size > 0; + const clientLabels = recentEntries.map((entry) => + 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. + const innerWidth = Math.max(0, width - 4); + const providerCol = Math.max(12, innerWidth - timeCol - modelCol - tokensCol - costCol - 5 - clientCol); + const msatsToSats = (msats?: number) => typeof msats === "number" ? msats / 1000 : 0; const lines: string[] = []; - lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"I/CR/CW/O".padEnd(tokensCol)} ${"I/O/T in sats".padEnd(costCol)} ${"BASE:PROVIDER".padEnd(providerCol)} ${"CLIENT".slice(0, clientCol)}${COLORS.reset}`); + 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); - for (const entry of recentEntries) { + 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, 18).padEnd(18); + const model = entry.modelId.slice(0, modelCol).padEnd(modelCol); const tokens = [ entry.promptTokens, entry.cacheReadInputTokens || 0, @@ -615,16 +631,16 @@ export function renderRecent(stats: UsageStats, width: number): string { ].join("/"); const baseUrl = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", ""); const provider = `${baseUrl}:${entry.provider || "unknown"}`.slice(0, providerCol).padEnd(providerCol); - const clientName = (entry.client || "unknown").slice(0, clientCol - 1); + 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}${clientName}${COLORS.reset}`); + 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}`); } 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, npubs: NpubEntry[] = []): string { +export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number, naming: ClientNaming): string { switch (activeTab) { case "overview": return renderOverview(stats, balance, status, width); case "today": return renderToday(stats, width); @@ -632,8 +648,8 @@ 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, npubs, width); - case "recent": return renderRecent(stats, width); + case "npubs": return renderNpubs(stats, naming, width); + case "recent": return renderRecent(stats, width, naming); default: return "Unknown tab"; } }