Files
routstrd/src/tui/usage/app.ts
T
redshift e3601d5de7 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
2026-09-11 07:31:01 +02:00

327 lines
11 KiB
TypeScript

import { getVisibleTabs } from "./constants.ts";
import type { Tab } from "./types.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,
nextSearchResult,
pageDown,
pageUp,
performSearch,
prevSearchResult,
scrollDown,
scrollToBottom,
scrollToTop,
scrollUp,
startSearch,
vimState,
} from "./state.ts";
import {
enterAlternateScreen,
eraseDown,
getHeight,
getWidth,
hideCursor,
leaveAlternateScreen,
moveCursor,
showCursor,
} from "./terminal.ts";
import { COLORS } from "./constants.ts";
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
import { checkForUpdates } from "../../utils/update-checker.ts";
export async function runUsageTui(): Promise<void> {
const running = await isDaemonRunning();
if (!running) {
console.log(`${COLORS.red}Error: routstrd daemon is not running.${COLORS.reset}`);
console.log(`Run ${COLORS.green}routstrd start${COLORS.reset} first.`);
process.exit(1);
}
const stdin = process.stdin;
const stdout = process.stdout;
const isInteractive = Boolean(stdout.isTTY && stdin.isTTY);
let currentTab: TabId = "overview";
let stats: UsageStats | null = null;
let balance: BalanceInfo | null = null;
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;
let cleanedUp = false;
let fetching = false;
// Update-check state — re-checked at most every 210 minutes to avoid
// spamming the npm registry. The first check happens shortly after
// startup so the TUI doesn't block on a network call.
const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
const UPDATE_CHECK_DELAY_MS = 3000;
let updateInfo: UpdateInfo | null = null;
let lastUpdateCheck = 0;
let updateCheckInProgress = false;
async function maybeCheckForUpdates(): Promise<void> {
if (updateCheckInProgress) return;
const now = Date.now();
if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
updateCheckInProgress = true;
lastUpdateCheck = now;
try {
const result = await checkForUpdates();
const outdated = result.packages.filter((p) => p.hasUpdate);
updateInfo = {
hasUpdate: result.hasUpdate,
text: outdated.length > 0
? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
: "",
};
render();
} catch {
// Silently ignore — update checks are best-effort
} finally {
updateCheckInProgress = false;
}
}
if (isInteractive) {
stdout.write(enterAlternateScreen() + hideCursor());
stdin.setRawMode?.(true);
stdin.resume();
stdin.setEncoding("utf-8");
}
function cleanup(exitCode = 0) {
if (cleanedUp) return;
cleanedUp = true;
if (refreshInterval) clearInterval(refreshInterval);
if (isInteractive) {
stdin.setRawMode?.(false);
stdin.pause();
stdout.write(showCursor() + leaveAlternateScreen());
} else {
stdout.write(showCursor());
}
process.exit(exitCode);
}
process.on("SIGINT", () => cleanup(0));
process.on("SIGTERM", () => cleanup(0));
/**
* Background fetch — async, never blocks rendering. Updates the state
* variables only on success, then triggers a repaint. Overlapping calls
* are skipped via the `fetching` guard so we don't race stale results.
*/
async function fetchData(): Promise<void> {
if (fetching) return;
fetching = true;
try {
const running = await isDaemonRunning();
if (!running) {
stats = null;
} else {
// Fire all 4 fetches concurrently — cuts the blocked window significantly.
const [newStats, newBalance, newStatus, newClients] = await Promise.all([
fetchUsageSummary(),
fetchBalance(),
fetchStatus(),
fetchClients(),
]);
if (newStats) stats = newStats;
if (newBalance) balance = newBalance;
if (newStatus) status = newStatus;
if (newClients && newClients.length > 0) clients = newClients;
const npubsVisible = hasAnyNpubs(clients);
visibleTabs = getVisibleTabs(npubsVisible);
if (currentTab === "npubs" && !npubsVisible) {
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;
}
// 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 {
fetching = false;
}
}
/**
* Synchronous paint — never awaits I/O. Reads the current state variables
* and writes to stdout. Safe to call from key handlers without blocking.
*/
function render(): void {
const width = getWidth();
const height = getHeight();
if (!stats) {
stdout.write(
moveCursor(1, 1) +
eraseDown() +
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
`Make sure routstrd is running.\n` +
`\nPress Q to quit.`
);
return;
}
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;
const footerSeparator = renderSeparator(width);
const footerLines = footerSeparator.split("\n").length - 1;
const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
const visibleContent = applyScrollToContent(content, contentViewportHeight);
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
}
const handleKey = (key: string) => {
if (vimState.isSearching) {
if (key === "\x1b" || key === "\x1b[3~") {
exitSearchMode();
render();
return;
}
if (key === "\r" || key === "\n") {
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
exitSearchMode();
render();
return;
}
if (key === "\x7f" || key === "\x08") {
vimState.searchQuery = vimState.searchQuery.slice(0, -1);
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
render();
return;
}
if (key === "\x03") {
exitSearchMode();
render();
return;
}
if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
vimState.searchQuery += key;
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
render();
}
return;
}
if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
if (key === "r" || key === "R") {
void fetchData();
return;
}
if (key === "a" || key === "A") {
autoRefresh = !autoRefresh;
render();
return;
}
if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
scrollDown();
render();
return;
}
if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
scrollUp();
render();
return;
}
if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
vimState.scrollPos = 0;
render();
return;
}
if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
vimState.scrollPos = 0;
render();
return;
}
if (key === "g") {
if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
scrollToBottom();
vimState.lastKey = "";
render();
return;
}
vimState.lastKey = "g";
vimState.lastKeyTime = Date.now();
scrollToTop();
render();
return;
}
if (key === "\x02") { pageUp(); render(); return; }
if (key === "\x06") { pageDown(); render(); return; }
if (key === "\x15") { scrollUp(10); render(); return; }
if (key === "\x04") { scrollDown(10); render(); return; }
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
if (key === "/") { startSearch(false); render(); return; }
if (key === "?") { startSearch(true); render(); return; }
if (key === "n") {
if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
else nextSearchResult(stats?.entries.length || 0);
render();
return;
}
if (key === "N") {
if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
else prevSearchResult(stats?.entries.length || 0);
render();
return;
}
if (key === "\x1b") { scrollToTop(); render(); return; }
const tab = visibleTabs.find((t) => t.key === key);
if (tab) {
currentTab = tab.id;
vimState.scrollPos = 0;
render();
}
};
if (isInteractive) stdin.on("data", handleKey);
await fetchData();
// Kick off the first update check after a short delay so the initial
// data fetch isn't blocked by a network round-trip to npm.
setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
refreshInterval = setInterval(() => {
if (autoRefresh) {
void fetchData();
}
void maybeCheckForUpdates();
}, 2000);
}