refacrtored tui

This commit is contained in:
redshift
2026-03-21 21:26:36 +00:00
parent 1451e54bb5
commit 550608a0d7
10 changed files with 1088 additions and 1236 deletions
+113
View File
@@ -0,0 +1,113 @@
# TUI refactor plan
## Goals
- Move the usage TUI implementation out of `src/cli/usage-tui.ts` into a dedicated `src/tui/` folder.
- Reduce the size and responsibility of the current monolithic file.
- Keep the existing CLI entrypoint stable so current usage does not break.
- Preserve behavior while making future TUI work easier.
## Current state
`src/cli/usage-tui.ts` currently mixes several concerns in one file:
- TUI-specific types and constants
- ANSI/terminal helpers
- scroll/search/vim navigation state
- data fetching from the daemon
- usage aggregation/stat helpers
- rendering for all tabs
- app lifecycle and keyboard event handling
This makes the file hard to extend safely.
## Refactor strategy
Do this incrementally and keep a thin compatibility wrapper in `src/cli/usage-tui.ts`.
### Target structure
- `src/tui/usage/index.ts`
- public entrypoint: `runUsageTui()`
- `src/tui/usage/types.ts`
- `UsageStats`, tab ids, tab metadata, derived stat types
- `src/tui/usage/constants.ts`
- tabs, colors, model/client color maps
- `src/tui/usage/terminal.ts`
- ANSI helpers, width/height helpers, `stripAnsi`
- `src/tui/usage/state.ts`
- vim/search/scroll state and state mutation helpers
- `src/tui/usage/data.ts`
- `fetchUsage()` and usage aggregation helpers
- `src/tui/usage/render.ts`
- shared render helpers and tab renderers
- `src/tui/usage/app.ts`
- main loop, render orchestration, input handling, cleanup
- `src/cli/usage-tui.ts`
- compatibility wrapper that re-exports or calls `runUsageTui()` from `src/tui/usage`
## Design choices
### 1. Keep CLI path compatibility
Do not delete the CLI file outright. Turn it into a tiny wrapper:
- minimal import from `../tui/usage/index.ts`
- export `runUsageTui()`
This avoids breaking any existing imports or scripts.
### 2. Separate pure logic from side effects
Keep these pure where possible:
- aggregation helpers
- formatting helpers
- render helpers that return strings
- scroll clamping logic
Keep side effects isolated in the app layer:
- reading terminal size
- writing to stdout
- raw mode setup
- signal handling
- interval scheduling
### 3. Avoid over-engineering
This should be a pragmatic refactor, not a framework:
- no unnecessary classes
- keep function-based design
- only extract modules around clear responsibility boundaries
### 4. Preserve behavior first
No UX changes unless needed to support the extraction.
That means:
- same tabs
- same keybindings
- same output format
- same fetch cadence
- same search/scroll behavior
## Implementation steps
1. Create `src/tui/usage/`.
2. Extract types/constants first.
3. Extract terminal helpers.
4. Extract data fetching + aggregation helpers.
5. Extract state/search/scroll logic.
6. Extract rendering helpers + tab renderers.
7. Build `app.ts` using the extracted modules.
8. Replace `src/cli/usage-tui.ts` with a thin wrapper.
9. Run a TypeScript/bun check and fix imports.
10. Smoke-test keyboard handling and rendering behavior.
## Risks
- circular imports between render/state/constants
- broken relative import paths during extraction
- subtle behavior regressions in scroll/search state
- terminal escape handling differences if helpers are split carelessly
## Validation checklist
- `src/cli/usage-tui.ts` still exposes `runUsageTui()`
- TUI starts from the same CLI path
- scroll still works for long content
- vim keys still work
- arrow keys still work
- tab switching still resets scroll
- search mode still works
- cleanup still restores cursor and alternate screen
## Non-goals
- redesigning the UI
- changing tab contents
- introducing tests unless needed for safety
- adding new features unrelated to the refactor
+1 -1236
View File
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
import { TABS } from "./constants.ts";
import { fetchUsage } 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, UsageStats } from "./types.ts";
import { isDaemonRunning } from "../../cli-shared.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 refreshInterval: ReturnType<typeof setInterval> | null = null;
let shouldUpdate = true;
let autoRefresh = true;
let cleanedUp = false;
let rendering = 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));
async function render(forceFetch = false) {
if (rendering) return;
rendering = true;
try {
const width = getWidth();
const height = getHeight();
if (forceFetch || shouldUpdate) {
stats = await fetchUsage(1000);
shouldUpdate = false;
}
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, width);
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) + renderTabs(currentTab) + 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);
} finally {
rendering = false;
}
}
const handleKey = (key: string) => {
if (vimState.isSearching) {
if (key === "\x1b" || key === "\x1b[3~") {
exitSearchMode();
void render(false);
return;
}
if (key === "\r" || key === "\n") {
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
exitSearchMode();
void render(false);
return;
}
if (key === "\x7f" || key === "\x08") {
vimState.searchQuery = vimState.searchQuery.slice(0, -1);
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
void render(false);
return;
}
if (key === "\x03") {
exitSearchMode();
void render(false);
return;
}
if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
vimState.searchQuery += key;
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
void render(false);
}
return;
}
if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
if (key === "r" || key === "R") {
shouldUpdate = true;
void render(true);
return;
}
if (key === "a" || key === "A") {
autoRefresh = !autoRefresh;
shouldUpdate = true;
void render(false);
return;
}
if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
scrollDown();
void render(false);
return;
}
if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
scrollUp();
void render(false);
return;
}
if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
const currentIdx = TABS.findIndex((t) => t.id === currentTab);
currentTab = TABS[(currentIdx + 1) % TABS.length]!.id;
vimState.scrollPos = 0;
void render(false);
return;
}
if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
const currentIdx = TABS.findIndex((t) => t.id === currentTab);
currentTab = TABS[(currentIdx - 1 + TABS.length) % TABS.length]!.id;
vimState.scrollPos = 0;
void render(false);
return;
}
if (key === "g") {
if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
scrollToBottom();
vimState.lastKey = "";
void render(false);
return;
}
vimState.lastKey = "g";
vimState.lastKeyTime = Date.now();
scrollToTop();
void render(false);
return;
}
if (key === "\x02") { pageUp(); void render(false); return; }
if (key === "\x06") { pageDown(); void render(false); return; }
if (key === "\x15") { scrollUp(10); void render(false); return; }
if (key === "\x04") { scrollDown(10); void render(false); return; }
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); void render(false); return; }
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); void render(false); return; }
if (key === "/") { startSearch(false); void render(false); return; }
if (key === "?") { startSearch(true); void render(false); return; }
if (key === "n") {
if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
else nextSearchResult(stats?.entries.length || 0);
void render(false);
return;
}
if (key === "N") {
if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
else prevSearchResult(stats?.entries.length || 0);
void render(false);
return;
}
if (key === "\x1b") { scrollToTop(); void render(false); return; }
const tab = TABS.find((t) => t.key === key);
if (tab) {
currentTab = tab.id;
vimState.scrollPos = 0;
void render(false);
}
};
if (isInteractive) stdin.on("data", handleKey);
await render(true);
refreshInterval = setInterval(() => {
if (autoRefresh) {
shouldUpdate = true;
void render(true);
}
}, 2000);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Tab } from "./types.ts";
export const TABS: Tab[] = [
{ id: "overview", name: "Overview", key: "1" },
{ id: "today", name: "Today", key: "2" },
{ id: "models", name: "Models", key: "3" },
{ id: "providers", name: "Providers", key: "4" },
{ id: "tokens", name: "Tokens", key: "5" },
{ id: "clients", name: "Clients", key: "6" },
{ id: "recent", name: "Recent", key: "7" },
];
export const COLORS = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
bgBlue: "\x1b[44m",
bgGreen: "\x1b[42m",
bgYellow: "\x1b[43m",
bright: "\x1b[1m",
};
export const MODEL_COLORS: Record<string, string> = {
"gpt-5.4": COLORS.magenta,
"minimax-m2.7": COLORS.cyan,
default: COLORS.white,
};
export const CLIENT_COLORS: Record<string, string> = {
opencode: COLORS.blue,
openclaw: COLORS.green,
"pi-agent": COLORS.yellow,
unknown: COLORS.dim,
default: COLORS.white,
};
+156
View File
@@ -0,0 +1,156 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts";
import { callDaemon, isDaemonRunning } from "../../cli-shared.ts";
import type { ClientStats, DayStats, ModelStats, ProviderStats, UsageStats } from "./types.ts";
export async function fetchUsage(limit = 1000): Promise<UsageStats | null> {
try {
const running = await isDaemonRunning();
if (!running) return null;
const result = await callDaemon(`/usage?limit=${limit}`);
if (result.error) return null;
const output = result.output as {
entries?: UsageTrackingEntry[];
totalEntries?: number;
totalSatsCost?: number;
recentSatsCost?: number;
limit?: number;
};
return {
entries: output?.entries || [],
totalEntries: output?.totalEntries || 0,
totalSatsCost: output?.totalSatsCost || 0,
recentSatsCost: output?.recentSatsCost || 0,
limit: output?.limit || limit,
};
} catch {
return null;
}
}
export function getTodayStart(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
export function formatDate(timestamp: number): string {
return new Date(timestamp).toISOString().split("T")[0] ?? "";
}
export function formatTime(timestamp: number): string {
return new Date(timestamp).toISOString().split("T")[1]?.slice(0, 8) ?? "";
}
export function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return n.toString();
}
export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
const days = new Map<string, DayStats>();
for (const entry of entries) {
const date = formatDate(entry.timestamp);
const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
days.set(date, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return days;
}
export function getHourlyToday(entries: UsageTrackingEntry[]): Map<number, DayStats> {
const todayStart = getTodayStart();
const hours = new Map<number, DayStats>();
for (const entry of entries) {
if (entry.timestamp < todayStart) continue;
const hour = new Date(entry.timestamp).getHours();
const existing = hours.get(hour) || {
date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
hours.set(hour, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return hours;
}
export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] {
const models = new Map<string, ModelStats>();
for (const entry of entries) {
const existing = models.get(entry.modelId) || {
modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
models.set(entry.modelId, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] {
const providers = new Map<string, ProviderStats>();
for (const entry of entries) {
const url = entry.baseUrl || "unknown";
const existing = providers.get(url) || {
baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
providers.set(url, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
const clients = new Map<string, ClientStats>();
for (const entry of entries) {
const client = entry.client || "unknown";
const existing = clients.get(client) || {
client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
clients.set(client, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getTotals(entries: UsageTrackingEntry[]) {
return entries.reduce(
(acc, entry) => ({
promptTokens: acc.promptTokens + entry.promptTokens,
completionTokens: acc.completionTokens + entry.completionTokens,
totalTokens: acc.totalTokens + entry.totalTokens,
satsCost: acc.satsCost + entry.satsCost,
}),
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }
);
}
+1
View File
@@ -0,0 +1 @@
export { runUsageTui } from "./app.ts";
+328
View File
@@ -0,0 +1,328 @@
import { CLIENT_COLORS, COLORS, MODEL_COLORS, TABS } from "./constants.ts";
import {
formatDate,
formatNumber,
formatTime,
getClientStats,
getDayStats,
getHourlyToday,
getModelStats,
getProviderStats,
getTodayStart,
getTotals,
} from "./data.ts";
import { vimState } from "./state.ts";
import { stripAnsi } from "./terminal.ts";
import type { TabId, UsageStats } from "./types.ts";
export function renderHeader(activeTab: TabId, width: number): string {
const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
const fill = width - title.length - help.length - vimIndicator.length - 6;
return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
}
export function renderSearchBar(): string {
if (!vimState.isSearching) return "";
const prompt = vimState.searchReverse ? "?" : "/";
const matches = vimState.searchResults.length > 0
? ` (${vimState.currentSearchIdx + 1}/${vimState.searchResults.length})`
: "";
const searchLine = `${COLORS.yellow}${prompt}${COLORS.reset}${vimState.searchQuery}${COLORS.dim}_${COLORS.reset}${matches} `;
const placeholder = `${COLORS.dim}type to search, Enter to confirm, Esc to cancel${COLORS.reset}`;
return `\n${searchLine}${placeholder}\n`;
}
export function renderTabs(activeTab: TabId): string {
const tabStr = TABS.map((tab) => tab.id === activeTab
? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}`
: `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
return `${" ".repeat(2)}${tabStr}\n`;
}
export function renderSeparator(width: number): string {
return `${COLORS.dim}${"─".repeat(width)}${COLORS.reset}\n`;
}
export function renderBox(lines: string[], width: number, title?: string): string {
const result: string[] = [];
const innerWidth = width - 4;
result.push(`┌─${title ? ` ${title} ` : " "}${("─".repeat(innerWidth - (title?.length || 0) - 2))}─┐`);
for (const line of lines) {
const padding = innerWidth - stripAnsi(line).length;
result.push(`│ ${line}${" ".repeat(Math.max(0, padding))} │`);
}
result.push(`└${"─".repeat(width - 2)}─┘`);
return result.join("\n");
}
export function renderBarChart(label: string, value: number, maxValue: number, width: number, color: string): string {
const maxBarWidth = width - label.length - 20;
const barLen = Math.round((value / maxValue) * maxBarWidth);
const bar = color + "█".repeat(barLen) + COLORS.reset;
const pct = maxValue > 0 ? ((value / maxValue) * 100).toFixed(1) : "0.0";
return `${label.padEnd(width - maxBarWidth - 15)}${bar} ${pct}%`;
}
export function renderOverview(stats: UsageStats, width: number): string {
const totals = getTotals(stats.entries);
const avgCost = stats.totalEntries > 0 ? stats.totalSatsCost / stats.totalEntries : 0;
const avgTokens = stats.totalEntries > 0 ? totals.totalTokens / stats.totalEntries : 0;
const halfWidth = Math.floor((width - 6) / 2);
const leftBox = [
`${COLORS.bold}Total Spent:${COLORS.reset} ${COLORS.green}${stats.totalSatsCost.toFixed(3)} sats${COLORS.reset}`,
`${COLORS.bold}Total Requests:${COLORS.reset} ${formatNumber(stats.totalEntries)}`,
`${COLORS.bold}Avg Cost/Req:${COLORS.reset} ${avgCost.toFixed(3)} sats`,
].map((l) => l.padEnd(halfWidth));
const rightBox = [
`${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber(totals.totalTokens)}`,
`${COLORS.bold}Avg Tokens/Req:${COLORS.reset} ${formatNumber(Math.round(avgTokens))}`,
`${COLORS.bold}Prompt/Comp:${COLORS.reset} ${(totals.promptTokens / Math.max(1, totals.completionTokens)).toFixed(2)}x`,
].map((l) => l.padEnd(halfWidth));
let output = renderBox(leftBox, width, "Summary Stats");
output += "\n" + renderBox(rightBox, width, "Token Stats");
const modelStats = getModelStats(stats.entries);
if (modelStats.length > 0) {
const maxCost = modelStats[0]!.satsCost;
const modelLines = modelStats.slice(0, 5).map((m) => renderBarChart(m.modelId, m.satsCost, maxCost, width - 4, MODEL_COLORS[m.modelId] || MODEL_COLORS.default || COLORS.white));
output += "\n" + renderBox(modelLines, width, "Top Models by Cost");
}
const clientStats = getClientStats(stats.entries);
if (clientStats.length > 0) {
const maxCost = clientStats[0]!.satsCost;
const clientLines = clientStats.slice(0, 5).map((c) => renderBarChart(c.client, c.satsCost, maxCost, width - 4, CLIENT_COLORS[c.client] || CLIENT_COLORS.default || COLORS.white));
output += "\n" + renderBox(clientLines, width, "Usage by Client");
}
return output;
}
export function renderToday(stats: UsageStats, width: number): string {
const hourly = getHourlyToday(stats.entries);
const todayStart = getTodayStart();
const currentHour = new Date().getHours();
const todayStats = { date: formatDate(Date.now()), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
for (const entry of stats.entries) {
if (entry.timestamp >= todayStart) {
todayStats.requests++;
todayStats.satsCost += entry.satsCost;
todayStats.promptTokens += entry.promptTokens;
todayStats.completionTokens += entry.completionTokens;
todayStats.totalTokens += entry.totalTokens;
}
}
const summaryLines = [
`${COLORS.bold}Date:${COLORS.reset} ${todayStats.date}`,
`${COLORS.bold}Requests:${COLORS.reset} ${todayStats.requests}`,
`${COLORS.bold}Cost:${COLORS.reset} ${COLORS.green}${todayStats.satsCost.toFixed(3)} sats${COLORS.reset}`,
`${COLORS.bold}Tokens:${COLORS.reset} ${formatNumber(todayStats.totalTokens)} (p: ${formatNumber(todayStats.promptTokens)} + c: ${formatNumber(todayStats.completionTokens)})`,
];
let output = renderBox(summaryLines, width, "Today");
output += "\n" + renderBox([], width, "Hourly Activity");
const hourLines: string[] = [];
const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1);
for (let h = 0; h <= currentHour; h++) {
const hStat = hourly.get(h);
const reqs = hStat?.requests || 0;
const cost = hStat?.satsCost || 0;
const time = `${h.toString().padStart(2, "0")}:00`;
const label = `${time} (${reqs} req, ${cost.toFixed(2)} sats)`;
hourLines.push(renderBarChart(label, cost, maxHourCost, width - 4, h === currentHour ? COLORS.green : COLORS.cyan));
}
output += "\n" + renderBox(hourLines.length > 0 ? hourLines : ["No activity today yet"], width);
const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7);
if (days.length > 1) {
const dayLines = days.slice(1).map((d) => `${d.date}: ${d.requests} req, ${d.satsCost.toFixed(2)} sats, ${formatNumber(d.totalTokens)} tokens`);
output += "\n" + renderBox(dayLines, width, "Recent Days");
}
return output;
}
export function renderModels(stats: UsageStats, width: number): string {
const modelStats = getModelStats(stats.entries);
if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models");
const totalCost = stats.totalSatsCost;
const maxCost = modelStats[0]!.satsCost;
const lines: string[] = [];
for (const model of modelStats) {
const color = MODEL_COLORS[model.modelId] || MODEL_COLORS.default || COLORS.white;
const pct = totalCost > 0 ? ((model.satsCost / totalCost) * 100).toFixed(1) : "0.0";
lines.push(`${color}${COLORS.bold}${model.modelId}${COLORS.reset}`);
lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${model.satsCost.toFixed(3)} sats (${pct}%)`);
lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${model.requests}`);
lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(model.totalTokens)}`);
lines.push(` ${COLORS.dim}Avg:${COLORS.reset} ${(model.satsCost / model.requests).toFixed(4)} sats/req`);
lines.push(` ${renderBarChart(" ", model.satsCost, maxCost, width - 4, color)}`);
lines.push("");
}
return renderBox(lines, width, "Model Breakdown");
}
export function renderProviders(stats: UsageStats, width: number): string {
const providerStats = getProviderStats(stats.entries);
if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers");
const lines: string[] = [];
for (const provider of providerStats) {
const shortUrl = provider.baseUrl.replace("https://", "").replace("http://", "");
lines.push(`${COLORS.cyan}${COLORS.bold}${shortUrl}${COLORS.reset}`);
lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${provider.requests}`);
lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${provider.satsCost.toFixed(3)} sats`);
lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(provider.totalTokens)}`);
lines.push("");
}
return renderBox(lines, width, "Provider Breakdown");
}
export function renderTokens(stats: UsageStats, width: number): string {
const totals = getTotals(stats.entries);
const modelStats = getModelStats(stats.entries);
const summaryLines = [
`${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`,
`${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`,
`${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber(totals.totalTokens)}`,
`${COLORS.bold}Prompt/Completion Ratio:${COLORS.reset} ${(totals.promptTokens / Math.max(1, totals.completionTokens)).toFixed(2)}x`,
`${COLORS.bold}Avg Tokens/Request:${COLORS.reset} ${(totals.totalTokens / Math.max(1, stats.totalEntries)).toFixed(0)}`,
];
let output = renderBox(summaryLines, width, "Token Summary");
if (modelStats.length > 0) {
const tokenLines = modelStats.slice(0, 6).map((m) => {
const color = MODEL_COLORS[m.modelId] || MODEL_COLORS.default;
const promptPct = m.totalTokens > 0 ? ((m.promptTokens / m.totalTokens) * 100).toFixed(0) : "0";
return `${color}${m.modelId.padEnd(15)}${COLORS.reset} ${formatNumber(m.totalTokens).padStart(8)} tokens (${promptPct}% prompt)`;
});
output += "\n" + renderBox(tokenLines, width, "Tokens by Model");
}
const sizeBuckets = {
tiny: { min: 0, max: 1000, count: 0, cost: 0 },
small: { min: 1000, max: 10000, count: 0, cost: 0 },
medium: { min: 10000, max: 50000, count: 0, cost: 0 },
large: { min: 50000, max: 100000, count: 0, cost: 0 },
huge: { min: 100000, max: Infinity, count: 0, cost: 0 },
};
for (const entry of stats.entries) {
for (const bucket of Object.values(sizeBuckets)) {
if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) {
bucket.count++;
bucket.cost += entry.satsCost;
break;
}
}
}
const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${bucket.count.toString().padStart(4)} reqs, ${bucket.cost.toFixed(2)} sats`);
output += "\n" + renderBox(sizeLines, width, "Request Size Distribution");
return output;
}
export function renderClients(stats: UsageStats, width: number): string {
const clientStats = getClientStats(stats.entries);
if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown");
const totalCost = stats.totalSatsCost;
const maxCost = clientStats[0]!.satsCost;
const lines: string[] = [];
lines.push(`${COLORS.bold}Client${"".padEnd(20)} Requests${"".padEnd(10)} Cost${"".padEnd(12)} Tokens${"".padEnd(10)} Avg Cost${COLORS.reset}`);
lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
for (const client of clientStats) {
const color = CLIENT_COLORS[client.client] || CLIENT_COLORS.default || COLORS.white;
const pct = totalCost > 0 ? ((client.satsCost / totalCost) * 100).toFixed(1) : "0.0";
const avgCost = client.requests > 0 ? (client.satsCost / client.requests).toFixed(4) : "0.0000";
lines.push(
`${color}${COLORS.bold}${client.client.padEnd(20)}${COLORS.reset}` +
`${client.requests.toString().padEnd(12)}` +
`${COLORS.green}${client.satsCost.toFixed(3).padEnd(12)} sats${COLORS.reset} (${pct}%)` +
`${COLORS.dim} ${formatNumber(client.totalTokens).padEnd(10)} ${avgCost.padEnd(10)} sats/req${COLORS.reset}`
);
lines.push(` ${renderBarChart("", client.satsCost, maxCost, width - 4, color)}`);
lines.push("");
}
let output = renderBox(lines, width, "Client Breakdown");
const clientModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
for (const entry of stats.entries) {
const client = entry.client || "unknown";
const model = entry.modelId;
if (!clientModelMap.has(client)) clientModelMap.set(client, new Map());
const modelMap = clientModelMap.get(client)!;
const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
modelMap.set(model, {
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
tokens: existing.tokens + entry.totalTokens,
});
}
const clientModelLines: string[] = [];
for (const topClient of clientStats.slice(0, 3)) {
const modelMap = clientModelMap.get(topClient.client);
if (!modelMap) continue;
const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${topClient.requests} reqs, ${topClient.satsCost.toFixed(2)} sats)`);
for (const [model, data] of models) {
clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${data.satsCost.toFixed(3)} sats`);
}
clientModelLines.push("");
}
if (clientModelLines.length > 0) {
output += "\n" + renderBox(clientModelLines, width, "Top Models per Client");
}
return output;
}
export function renderRecent(stats: UsageStats, width: number): string {
const recentEntries = stats.entries.slice(0, 50);
if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
const lines: string[] = [];
lines.push(`${COLORS.bold}${"TIME".padEnd(10)} ${"MODEL".padEnd(18)} ${"TOKENS".padEnd(10)} ${"COST".padEnd(12)} ${"PROVIDER".slice(0, Math.max(0, width - 60))}${COLORS.reset}`);
lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
for (const entry of recentEntries) {
const time = formatTime(entry.timestamp).slice(0, 8);
const model = entry.modelId.slice(0, 18).padEnd(18);
const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
const cost = `${entry.satsCost.toFixed(3).padEnd(10)} sats`;
const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 60));
const color = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
lines.push(`${COLORS.dim}${time}${COLORS.reset} ${color}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
}
return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
}
export function renderTabContent(activeTab: TabId, stats: UsageStats, width: number): string {
switch (activeTab) {
case "overview": return renderOverview(stats, width);
case "today": return renderToday(stats, width);
case "models": return renderModels(stats, width);
case "providers": return renderProviders(stats, width);
case "tokens": return renderTokens(stats, width);
case "clients": return renderClients(stats, width);
case "recent": return renderRecent(stats, width);
default: return "Unknown tab";
}
}
+100
View File
@@ -0,0 +1,100 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts";
import type { VimState } from "./types.ts";
export const vimState: VimState = {
scrollPos: 0,
searchQuery: "",
searchResults: [],
currentSearchIdx: 0,
isSearching: false,
searchReverse: false,
mode: "normal",
lastKey: "",
lastKeyTime: 0,
};
let maxScrollLines = 0;
export function clampScrollPos(contentHeight: number, viewportHeight: number): void {
const maxScroll = Math.max(0, contentHeight - viewportHeight);
vimState.scrollPos = Math.max(0, Math.min(vimState.scrollPos, maxScroll));
}
export function applyScrollToContent(content: string, viewportHeight: number): string {
const lines = content.split("\n");
maxScrollLines = Math.max(0, lines.length - Math.max(0, viewportHeight));
clampScrollPos(lines.length, viewportHeight);
if (viewportHeight <= 0) {
return "";
}
return lines.slice(vimState.scrollPos, vimState.scrollPos + viewportHeight).join("\n");
}
export function scrollDown(lines = 1): void {
vimState.scrollPos = Math.min(vimState.scrollPos + lines, maxScrollLines);
}
export function scrollUp(lines = 1): void {
vimState.scrollPos = Math.max(vimState.scrollPos - lines, 0);
}
export function scrollToTop(): void {
vimState.scrollPos = 0;
}
export function scrollToBottom(): void {
vimState.scrollPos = maxScrollLines;
}
export function pageUp(): void {
scrollUp(15);
}
export function pageDown(): void {
scrollDown(15);
}
export function startSearch(reverse = false): void {
vimState.isSearching = true;
vimState.searchReverse = reverse;
vimState.searchQuery = "";
vimState.mode = "search";
}
export function performSearch(query: string, entries: UsageTrackingEntry[]): void {
vimState.searchQuery = query;
vimState.searchResults = [];
vimState.currentSearchIdx = 0;
if (!query) {
vimState.searchResults = [];
return;
}
const lowerQuery = query.toLowerCase();
entries.forEach((entry, idx) => {
const searchable = [entry.modelId, entry.baseUrl || "", entry.client || ""].join(" ").toLowerCase();
if (searchable.includes(lowerQuery)) {
vimState.searchResults.push(idx);
}
});
}
export function nextSearchResult(totalEntries: number): void {
if (vimState.searchResults.length === 0) return;
vimState.currentSearchIdx = (vimState.currentSearchIdx + 1) % vimState.searchResults.length;
vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines);
}
export function prevSearchResult(totalEntries: number): void {
if (vimState.searchResults.length === 0) return;
vimState.currentSearchIdx = (vimState.currentSearchIdx - 1 + vimState.searchResults.length) % vimState.searchResults.length;
vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines);
}
export function exitSearchMode(): void {
vimState.isSearching = false;
vimState.mode = "normal";
}
+39
View File
@@ -0,0 +1,39 @@
export function clearScreen(): string {
return "\x1b[2J\x1b[H";
}
export function enterAlternateScreen(): string {
return "\x1b[?1049h";
}
export function leaveAlternateScreen(): string {
return "\x1b[?1049l";
}
export function hideCursor(): string {
return "\x1b[?25l";
}
export function showCursor(): string {
return "\x1b[?25h";
}
export function moveCursor(row: number, col: number): string {
return `\x1b[${row};${col}H`;
}
export function eraseDown(): string {
return "\x1b[J";
}
export function getWidth(): number {
return process.stdout.columns || 80;
}
export function getHeight(): number {
return process.stdout.rows || 24;
}
export function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*m/g, "");
}
+65
View File
@@ -0,0 +1,65 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts";
export interface UsageStats {
entries: UsageTrackingEntry[];
totalEntries: number;
totalSatsCost: number;
recentSatsCost: number;
limit: number;
}
export interface DayStats {
date: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface ModelStats {
modelId: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface ProviderStats {
baseUrl: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export interface ClientStats {
client: string;
requests: number;
satsCost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "recent";
export interface Tab {
id: TabId;
name: string;
key: string;
}
export interface VimState {
scrollPos: number;
searchQuery: string;
searchResults: number[];
currentSearchIdx: number;
isSearching: boolean;
searchReverse: boolean;
mode: "normal" | "search";
lastKey: string;
lastKeyTime: number;
}