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 `-<npub tail>` 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
This commit is contained in:
redshift
2026-09-11 07:31:01 +02:00
parent dd4e5eb22d
commit e3601d5de7
4 changed files with 203 additions and 24 deletions
+7 -2
View File
@@ -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<void> {
let status: StatusInfo | null = null;
let clients: ClientInfo[] = [];
let npubs: NpubEntry[] = [];
let naming: ClientNaming = emptyClientNaming();
let visibleTabs: Tab[] = getVisibleTabs(false);
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let autoRefresh = true;
@@ -153,6 +154,10 @@ export async function runUsageTui(): Promise<void> {
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<void> {
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;
+83
View File
@@ -172,3 +172,86 @@ export async function fetchNpubs(): Promise<NpubEntry[]> {
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<string, string>;
/** npub -> display name (null when unset). */
npubNames: Map<string, string | null>;
/** trailing npub suffix -> npub. */
npubsBySuffix: Map<string, string>;
}
export function buildClientNaming(clients: ClientInfo[], npubs: NpubEntry[]): ClientNaming {
const ownersByClientId = new Map<string, string>();
for (const c of clients) {
if (c.ownerNpub) ownersByClientId.set(c.clientId, c.ownerNpub);
}
const npubNames = new Map<string, string | null>();
const npubsBySuffix = new Map<string, string>();
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 `-<npub tail>` 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 `-<npub tail>` 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([], []);
}
+82 -7
View File
@@ -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");
});
});
+31 -15
View File
@@ -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<string, NpubEntry>();
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 `-<npub tail>` 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";
}
}