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