mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
refacrtored tui
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { runUsageTui } from "./app.ts";
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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, "");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user