From 550608a0d7841a2782e2afe30ee104b8062a776a Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:26:36 +0000 Subject: [PATCH] refacrtored tui --- src/TUI refactor.md | 113 ++++ src/cli/usage-tui.ts | 1237 +----------------------------------- src/tui/usage/app.ts | 243 +++++++ src/tui/usage/constants.ts | 42 ++ src/tui/usage/data.ts | 156 +++++ src/tui/usage/index.ts | 1 + src/tui/usage/render.ts | 328 ++++++++++ src/tui/usage/state.ts | 100 +++ src/tui/usage/terminal.ts | 39 ++ src/tui/usage/types.ts | 65 ++ 10 files changed, 1088 insertions(+), 1236 deletions(-) create mode 100644 src/TUI refactor.md create mode 100644 src/tui/usage/app.ts create mode 100644 src/tui/usage/constants.ts create mode 100644 src/tui/usage/data.ts create mode 100644 src/tui/usage/index.ts create mode 100644 src/tui/usage/render.ts create mode 100644 src/tui/usage/state.ts create mode 100644 src/tui/usage/terminal.ts create mode 100644 src/tui/usage/types.ts diff --git a/src/TUI refactor.md b/src/TUI refactor.md new file mode 100644 index 0000000..ff7547a --- /dev/null +++ b/src/TUI refactor.md @@ -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 diff --git a/src/cli/usage-tui.ts b/src/cli/usage-tui.ts index 2057185..8256740 100644 --- a/src/cli/usage-tui.ts +++ b/src/cli/usage-tui.ts @@ -1,1236 +1 @@ -/** - * routstrd Usage Monitor TUI - * - * An htop-like terminal UI for monitoring routstr usage stats. - * Navigate with arrow keys or number keys (1-6) for tabs. - * - * Usage: bun run src/cli/usage-tui.ts - */ - -import type { UsageTrackingEntry } from "../daemon/types.ts"; -import { callDaemon, isDaemonRunning } from "../cli-shared.ts"; - -// ═══════════════════════════════════════════════════════════════ -// Types -// ═══════════════════════════════════════════════════════════════ - -interface UsageStats { - entries: UsageTrackingEntry[]; - totalEntries: number; - totalSatsCost: number; - recentSatsCost: number; - limit: number; -} - -interface DayStats { - date: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -interface ModelStats { - modelId: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -interface ProviderStats { - baseUrl: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -interface ClientStats { - client: string; - requests: number; - satsCost: number; - promptTokens: number; - completionTokens: number; - totalTokens: number; -} - -type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "recent"; - -interface Tab { - id: TabId; - name: string; - key: string; -} - -// ═══════════════════════════════════════════════════════════════ -// Vim Mode State -// ═══════════════════════════════════════════════════════════════ - -interface VimState { - scrollPos: number; - searchQuery: string; - searchResults: number[]; - currentSearchIdx: number; - isSearching: boolean; - searchReverse: boolean; - mode: "normal" | "search"; - lastKey: string; - lastKeyTime: number; -} - -const vimState: VimState = { - scrollPos: 0, - searchQuery: "", - searchResults: [], - currentSearchIdx: 0, - isSearching: false, - searchReverse: false, - mode: "normal", - lastKey: "", - lastKeyTime: 0, -}; - -let maxScrollLines = 0; - -// ═══════════════════════════════════════════════════════════════ -// Constants -// ═══════════════════════════════════════════════════════════════ - -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" }, -]; - -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", -}; - -const MODEL_COLORS: Record = { - "gpt-5.4": COLORS.magenta, - "minimax-m2.7": COLORS.cyan, - "default": COLORS.white, -}; - -const CLIENT_COLORS: Record = { - "opencode": COLORS.blue, - "openclaw": COLORS.green, - "pi-agent": COLORS.yellow, - "unknown": COLORS.dim, - "default": COLORS.white, -}; - -// ═══════════════════════════════════════════════════════════════ -// ANSI Escape Helpers -// ═══════════════════════════════════════════════════════════════ - -function clearScreen(): string { - return "\x1b[2J\x1b[H"; -} - -function enterAlternateScreen(): string { - return "\x1b[?1049h"; -} - -function leaveAlternateScreen(): string { - return "\x1b[?1049l"; -} - -function hideCursor(): string { - return "\x1b[?25l"; -} - -function showCursor(): string { - return "\x1b[?25h"; -} - -function moveCursor(row: number, col: number): string { - return `\x1b[${row};${col}H`; -} - -function eraseDown(): string { - return "\x1b[J"; -} - -function getWidth(): number { - return process.stdout.columns || 80; -} - -function getHeight(): number { - return process.stdout.rows || 24; -} - -function clampScrollPos(contentHeight: number, viewportHeight: number): void { - const maxScroll = Math.max(0, contentHeight - viewportHeight); - vimState.scrollPos = Math.max(0, Math.min(vimState.scrollPos, maxScroll)); -} - -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"); -} - -// Vim mode helpers -function scrollDown(lines = 1): void { - vimState.scrollPos = Math.min(vimState.scrollPos + lines, maxScrollLines); -} - -function scrollUp(lines = 1): void { - vimState.scrollPos = Math.max(vimState.scrollPos - lines, 0); -} - -function scrollToTop(): void { - vimState.scrollPos = 0; -} - -function scrollToBottom(): void { - vimState.scrollPos = maxScrollLines; -} - -function pageUp(): void { - scrollUp(15); -} - -function pageDown(): void { - scrollDown(15); -} - -function startSearch(reverse = false): void { - vimState.isSearching = true; - vimState.searchReverse = reverse; - vimState.searchQuery = ""; - vimState.mode = "search"; -} - -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); - } - }); -} - -function nextSearchResult(totalEntries: number): void { - if (vimState.searchResults.length === 0) return; - vimState.currentSearchIdx = (vimState.currentSearchIdx + 1) % vimState.searchResults.length; - // Map search result index to scroll position (approximate) - vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines); -} - -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); -} - -function exitSearchMode(): void { - vimState.isSearching = false; - vimState.mode = "normal"; -} - -// ═══════════════════════════════════════════════════════════════ -// Data Fetching -// ═══════════════════════════════════════════════════════════════ - -async function fetchUsage(limit = 1000): Promise { - 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; - } -} - -// ═══════════════════════════════════════════════════════════════ -// Data Analysis -// ═══════════════════════════════════════════════════════════════ - -function getTodayStart(): number { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); -} - -function formatDate(timestamp: number): string { - return new Date(timestamp).toISOString().split("T")[0] ?? ""; -} - -function formatTime(timestamp: number): string { - return new Date(timestamp).toISOString().split("T")[1]?.slice(0, 8) ?? ""; -} - -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(); -} - -function getDayStats(entries: UsageTrackingEntry[]): Map { - const days = new Map(); - - 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; -} - -function getHourlyToday(entries: UsageTrackingEntry[]): Map { - const todayStart = getTodayStart(); - const hours = new Map(); - - 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; -} - -function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] { - const models = new Map(); - - 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); -} - -function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] { - const providers = new Map(); - - 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); -} - -function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] { - const clients = new Map(); - - 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); -} - -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 } - ); -} - -// ═══════════════════════════════════════════════════════════════ -// Rendering -// ═══════════════════════════════════════════════════════════════ - -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 [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`; -} - -function renderSearchBar(width: number): string { - if (!vimState.isSearching) return ""; - - const prompt = vimState.searchReverse ? "?" : "/"; - const query = vimState.searchQuery; - const matches = vimState.searchResults.length > 0 - ? ` (${vimState.currentSearchIdx + 1}/${vimState.searchResults.length})` - : ""; - - const searchLine = `${COLORS.yellow}${prompt}${COLORS.reset}${query}${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`; -} - -function renderTabs(activeTab: TabId, width: number): string { - const tabStr = TABS.map((tab) => { - if (tab.id === activeTab) { - return `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}`; - } - return `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`; - }).join(" "); - - return `${" ".repeat(2)}${tabStr}\n`; -} - -function renderSeparator(width: number): string { - return `${COLORS.dim}${"─".repeat(width)}${COLORS.reset}\n`; -} - -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"); -} - -function stripAnsi(str: string): string { - return str.replace(/\x1b\[[0-9;]*m/g, ""); -} - -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}%`; -} - -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"); - - // Quick model breakdown - const modelStats = getModelStats(stats.entries); - if (modelStats.length > 0) { - const maxCost = modelStats[0]!.satsCost; - const modelLines = modelStats.slice(0, 5).map((m) => { - const color = MODEL_COLORS[m.modelId] || MODEL_COLORS.default || COLORS.white; - return renderBarChart(m.modelId, m.satsCost, maxCost, width - 4, color); - }); - output += "\n" + renderBox(modelLines, width, "Top Models by Cost"); - } - - // Client distribution - const clientStats = getClientStats(stats.entries); - if (clientStats.length > 0) { - const maxCost = clientStats[0]!.satsCost; - const clientLines = clientStats.slice(0, 5).map((c) => { - const color = CLIENT_COLORS[c.client] || CLIENT_COLORS.default || COLORS.white; - return renderBarChart(c.client, c.satsCost, maxCost, width - 4, color); - }); - output += "\n" + renderBox(clientLines, width, "Usage by Client"); - } - - return output; -} - -function renderToday(stats: UsageStats, width: number): string { - const hourly = getHourlyToday(stats.entries); - const todayStart = getTodayStart(); - const now = new Date(); - const currentHour = now.getHours(); - - const todayStats: DayStats = { - 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"); - - // Hourly bar chart - 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)`; - const barColor = h === currentHour ? COLORS.green : COLORS.cyan; - hourLines.push(renderBarChart(label, cost, maxHourCost, width - 4, barColor)); - } - - if (hourLines.length > 0) { - output += "\n" + renderBox(hourLines, width); - } else { - output += "\n" + renderBox(["No activity today yet"], width); - } - - // Recent days - const dayStats = getDayStats(stats.entries); - const days = Array.from(dayStats.values()).slice(0, 7); - - if (days.length > 1) { - const dayLines = days.slice(1).map((d) => { - return `${d.date}: ${d.requests} req, ${d.satsCost.toFixed(2)} sats, ${formatNumber(d.totalTokens)} tokens`; - }); - output += "\n" + renderBox(dayLines, width, "Recent Days"); - } - - return output; -} - -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"); -} - -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"); -} - -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"); - - // Token by model - 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"); - } - - // Size categories - 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 [name, bucket] of Object.entries(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]) => { - const label = `${name.padEnd(6)}: ${bucket.count.toString().padStart(4)} reqs, ${bucket.cost.toFixed(2)} sats`; - return label; - }); - - output += "\n" + renderBox(sizeLines, width, "Request Size Distribution"); - - return output; -} - -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[] = []; - - // Summary by client - 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"); - - // Model breakdown per client - const clientModelMap = new Map>(); - - 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, - }); - } - - // Show top models for top clients - const topClients = clientStats.slice(0, 3); - const innerWidth = Math.floor((width - 6) / 2); - - const clientModelLines: string[] = []; - for (const topClient of topClients) { - 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) { - const color = MODEL_COLORS[model] || MODEL_COLORS.default; - clientModelLines.push(` ${color}${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; -} - -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[] = []; - - // Header - 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)`); -} - -// ═══════════════════════════════════════════════════════════════ -// Main Entry Point -// ═══════════════════════════════════════════════════════════════ - -async function main(): Promise { - // Check if daemon is running - 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 | 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; - } - - let content: string; - - switch (currentTab) { - case "overview": - content = renderOverview(stats, width); - break; - case "today": - content = renderToday(stats, width); - break; - case "models": - content = renderModels(stats, width); - break; - case "providers": - content = renderProviders(stats, width); - break; - case "tokens": - content = renderTokens(stats, width); - break; - case "clients": - content = renderClients(stats, width); - break; - case "recent": - content = renderRecent(stats, width); - break; - default: - content = "Unknown tab"; - } - - 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: j/k scroll, / search, g top, G bottom${COLORS.reset}` : ""}`; - const chrome = - renderHeader(currentTab, width) + - renderTabs(currentTab, width) + - renderSeparator(width) + - renderSearchBar(width); - 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; - - const output = - moveCursor(1, 1) + - eraseDown() + - chrome + - visibleContent + - footerBlock; - - stdout.write(output); - } finally { - rendering = false; - } - } - - const handleKey = (key: string) => { - // Handle search mode separately - if (vimState.isSearching) { - if (key === "\x1b" || key === "\x1b[3~") { // Escape - exitSearchMode(); - void render(false); - return; - } - if (key === "\r" || key === "\n") { // Enter - confirm search - if (stats?.entries) { - performSearch(vimState.searchQuery, stats.entries); - } - exitSearchMode(); - void render(false); - return; - } - if (key === "\x7f" || key === "\x08") { // Backspace - vimState.searchQuery = vimState.searchQuery.slice(0, -1); - if (stats?.entries) { - performSearch(vimState.searchQuery, stats.entries); - } - void render(false); - return; - } - if (key === "\x03") { // Ctrl+C - exitSearchMode(); - void render(false); - return; - } - // Any printable character - 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; - } - return; - } - - if (key === "q" || key === "Q" || key === "\u0003") { - cleanup(0); - return; - } - - if (key === "r" || key === "R") { - shouldUpdate = true; - void render(true); - return; - } - - if (key === "a" || key === "A") { - autoRefresh = !autoRefresh; - shouldUpdate = true; - void render(false); - return; - } - - // Vim mode navigation - // Navigation: j, k, l, h - if (key === "j") { // down - scrollDown(); - void render(false); - return; - } - if (key === "k") { // up - scrollUp(); - void render(false); - return; - } - if (key === "l") { // right - next tab - const currentIdx = TABS.findIndex((t) => t.id === currentTab); - const nextIdx = (currentIdx + 1) % TABS.length; - currentTab = TABS[nextIdx]!.id; - vimState.scrollPos = 0; - void render(false); - return; - } - if (key === "h") { // left - prev tab - const currentIdx = TABS.findIndex((t) => t.id === currentTab); - const prevIdx = (currentIdx - 1 + TABS.length) % TABS.length; - currentTab = TABS[prevIdx]!.id; - vimState.scrollPos = 0; - void render(false); - return; - } - - // Vim: g - go to top (gg in vim means go to top, but we just use g for simplicity) - // For G (bottom), we use a double-tap timeout: press g twice quickly - if (key === "g") { - if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) { - // Double-tap g = G (go to bottom) - scrollToBottom(); - vimState.lastKey = ""; - void render(false); - return; - } - vimState.lastKey = "g"; - vimState.lastKeyTime = Date.now(); - scrollToTop(); - void render(false); - return; - } - - // Page up/down: Ctrl+b, Ctrl+f, Ctrl+u, Ctrl+d - if (key === "\x02") { // Ctrl+b (back) - pageUp(); - void render(false); - return; - } - if (key === "\x06") { // Ctrl+f (forward) - pageDown(); - void render(false); - return; - } - if (key === "\x15") { // Ctrl+u (up half page) - scrollUp(10); - void render(false); - return; - } - if (key === "\x04") { // Ctrl+d (down half page) - scrollDown(10); - void render(false); - return; - } - - // Home/End keys (common TUI shortcuts) - if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { // Home - scrollToTop(); - void render(false); - return; - } - if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { // End - scrollToBottom(); - void render(false); - return; - } - - // Search: / forward, ? reverse - if (key === "/") { - startSearch(false); - void render(false); - return; - } - if (key === "?") { - startSearch(true); - void render(false); - return; - } - - // Next/prev search match - 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; - } - - // Escape - clear scroll or exit vim mode - 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); -} - -export async function runUsageTui(): Promise { - await main().catch((err) => { - process.stdout.write(showCursor() + leaveAlternateScreen()); - console.error("Error:", err); - process.exit(1); - }); -} +export { runUsageTui } from "../tui/usage/index.ts"; diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts new file mode 100644 index 0000000..8d6f38a --- /dev/null +++ b/src/tui/usage/app.ts @@ -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 { + 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 | 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); +} diff --git a/src/tui/usage/constants.ts b/src/tui/usage/constants.ts new file mode 100644 index 0000000..93bb6a5 --- /dev/null +++ b/src/tui/usage/constants.ts @@ -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 = { + "gpt-5.4": COLORS.magenta, + "minimax-m2.7": COLORS.cyan, + default: COLORS.white, +}; + +export const CLIENT_COLORS: Record = { + opencode: COLORS.blue, + openclaw: COLORS.green, + "pi-agent": COLORS.yellow, + unknown: COLORS.dim, + default: COLORS.white, +}; diff --git a/src/tui/usage/data.ts b/src/tui/usage/data.ts new file mode 100644 index 0000000..c94d6e9 --- /dev/null +++ b/src/tui/usage/data.ts @@ -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 { + 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 { + const days = new Map(); + 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 { + const todayStart = getTodayStart(); + const hours = new Map(); + 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(); + 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(); + 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(); + 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 } + ); +} diff --git a/src/tui/usage/index.ts b/src/tui/usage/index.ts new file mode 100644 index 0000000..8988242 --- /dev/null +++ b/src/tui/usage/index.ts @@ -0,0 +1 @@ +export { runUsageTui } from "./app.ts"; diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts new file mode 100644 index 0000000..053a308 --- /dev/null +++ b/src/tui/usage/render.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>(); + + 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"; + } +} diff --git a/src/tui/usage/state.ts b/src/tui/usage/state.ts new file mode 100644 index 0000000..d655c0f --- /dev/null +++ b/src/tui/usage/state.ts @@ -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"; +} diff --git a/src/tui/usage/terminal.ts b/src/tui/usage/terminal.ts new file mode 100644 index 0000000..b51ba54 --- /dev/null +++ b/src/tui/usage/terminal.ts @@ -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, ""); +} diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts new file mode 100644 index 0000000..282f029 --- /dev/null +++ b/src/tui/usage/types.ts @@ -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; +}