From 35d8cb5de6167974c6ca130e410f4f1f0e6a49e3 Mon Sep 17 00:00:00 2001 From: Hannibal Smith Date: Wed, 8 Apr 2026 08:29:40 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20tui.js=20=E2=80=94=20live=20ANSI=20term?= =?UTF-8?q?inal=20dashboard=20(B.A.=20objective)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements token-monitor#3 objective 3: node tui.js starts a live terminal dashboard: - Full layout: Anthropic Teams (severity-colored) + xAI section - 60s auto-refresh with 1s countdown - Flicker-free via cursor-home (not clear-screen) - [r] forces immediate refresh - [q] or Ctrl-C exits cleanly, restores terminal + cursor - Severity coloring: green=ok, yellow=warning, red=critical/maxed - xAI section hidden when XAI_MANAGEMENT_KEY not set (graceful degradation) - Budget decision: recommended provider + avoid list prominently displayed - Alerts section (up to 3, warning/critical only) - No external npm dependencies — pure Node stdlib + project analyze.js Data source: spawns analyze.js --budget-json internally (reuses cached probe data if fresh, avoids double-probe on each TUI refresh) Co-authored-by: Hannibal Smith --- tui.js | 351 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 tui.js diff --git a/tui.js b/tui.js new file mode 100644 index 0000000..ff0fe0a --- /dev/null +++ b/tui.js @@ -0,0 +1,351 @@ +#!/usr/bin/env node +/** + * tui.js — Token Monitor live terminal dashboard + * + * Live ANSI dashboard. Refreshes every 60 seconds. + * No external dependencies — pure Node stdlib + project modules. + * + * Usage: + * node tui.js + * + * Keys: + * [r] — force immediate refresh + * [q] — quit cleanly (restores terminal) + * + * Degrades gracefully when XAI_MANAGEMENT_KEY is not set. + */ + +import { spawnSync } from 'child_process'; +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ── ANSI helpers ────────────────────────────────────────────────────────────── + +const ESC = '\x1b'; +const RESET = `${ESC}[0m`; +const BOLD = `${ESC}[1m`; +const DIM = `${ESC}[2m`; +const RED = `${ESC}[31m`; +const GREEN = `${ESC}[32m`; +const YELLOW = `${ESC}[33m`; +const CYAN = `${ESC}[36m`; +const WHITE = `${ESC}[37m`; +const HIDE_CURSOR = `${ESC}[?25l`; +const SHOW_CURSOR = `${ESC}[?25h`; +const CURSOR_HOME = `${ESC}[H`; +const CLEAR_SCREEN = `${ESC}[2J`; +const CLEAR_EOL = `${ESC}[K`; + +function color(severity) { + switch (severity) { + case 'ok': return GREEN; + case 'warning': return YELLOW; + case 'critical': return RED; + case 'dormant': return DIM; + default: return DIM; + } +} + +function severityIcon(severity) { + switch (severity) { + case 'ok': return '✓'; + case 'warning': return '!'; + case 'critical': return '✗'; + default: return '~'; + } +} + +function formatDuration(seconds) { + if (seconds == null || isNaN(seconds) || seconds < 0) return '?'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m > 0 ? m + 'm' : ''}`.trim(); + if (m > 0) return `${m}m`; + return `${Math.round(seconds)}s`; +} + +function pct(v) { + if (v == null) return ' ?%'; + return `${Math.round(v * 100)}%`; +} + +function bar(fraction, width = 7) { + if (fraction == null) return ' '.repeat(width); + const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width); + const empty = width - filled; + return '█'.repeat(filled) + '░'.repeat(empty); +} + +// ── Data fetching ───────────────────────────────────────────────────────────── + +async function fetchBudgetData() { + try { + const result = spawnSync('node', [join(__dirname, 'analyze.js'), '--budget-json'], { + encoding: 'utf-8', + timeout: 30_000, + env: process.env, + }); + if (result.status !== 0 || !result.stdout) { + return { error: result.stderr?.trim() || 'analyze.js returned no output' }; + } + return JSON.parse(result.stdout); + } catch (err) { + return { error: err.message }; + } +} + +// ── Layout ──────────────────────────────────────────────────────────────────── + +const WIDTH = 75; + +function hline(char = '─', w = WIDTH - 2) { return char.repeat(w); } + +function border(content) { + return `│ ${content}${CLEAR_EOL}`; +} + +function header(ts) { + const tsStr = ts ? new Date(ts).toUTCString().replace(' GMT', ' UTC') : ''; + const title = 'Token Monitor'; + const pad = WIDTH - 4 - title.length - tsStr.length; + return `┌─${BOLD}${CYAN} ${title} ${RESET}${'─'.repeat(Math.max(0, pad))}${DIM}${tsStr}${RESET} ─┐`; +} + +function footer() { + const hint = '[r] refresh [q] quit'; + const pad = WIDTH - 4 - hint.length; + return `└${'─'.repeat(pad > 0 ? pad : 0)}${DIM}${hint}${RESET}─┘`; +} + +function renderAnthropicSection(providers, decision) { + const lines = []; + lines.push(border(`${BOLD}${CYAN}ANTHROPIC TEAMS${RESET}`)); + + const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio']; + + for (const name of TEAMS) { + const p = providers?.[name]; + if (!p) continue; + + const sev = p.severity || 'unknown'; + const c = color(sev); + const icon = severityIcon(sev); + const shortName = name.replace('team-', ''); + + if (sev === 'critical') { + const resetStr = p.reset_in_seconds != null ? `resets ${formatDuration(p.reset_in_seconds)}` : ''; + lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${RED}MAXED${RESET} ${DIM}${resetStr}${RESET}`)); + } else if (p.utilization_7d == null && p.utilization_5h == null) { + lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${DIM}DORMANT${RESET}`)); + } else if (p.status === 'invalid_key') { + lines.push(border(` ${DIM}~${RESET} ${name.padEnd(16)} ${DIM}invalid key${RESET}`)); + } else { + const util5h = p.utilization_5h != null ? `5h:${pct(p.utilization_5h).padStart(4)}` : ''; + const util7d = p.utilization_7d != null ? `7d:${pct(p.utilization_7d).padStart(4)}` : ''; + const resetStr = p.reset_in_seconds != null ? ` resets ${formatDuration(p.reset_in_seconds)}` : ''; + lines.push(border(` ${c}${icon}${RESET} ${name.padEnd(16)} ${util5h} ${util7d}${DIM}${resetStr}${RESET}`)); + } + } + + return lines; +} + +function renderXaiSection(xai) { + if (!xai || xai.error) return []; + const lines = []; + lines.push(border(`${BOLD}${CYAN}xAI${RESET}`)); + + if (xai.by_key) { + const keys = Object.entries(xai.by_key); + if (keys.length === 0) { + lines.push(border(` ${DIM}(no spend this period)${RESET}`)); + } else { + const maxSpend = Math.max(...keys.map(([, v]) => v.spend_usd)); + for (const [keyName, data] of keys) { + const fraction = maxSpend > 0 ? data.spend_usd / maxSpend : 0; + const barStr = bar(fraction, 7); + const spendStr = `$${data.spend_usd.toFixed(2)}`.padStart(8); + lines.push(border(` ${keyName.padEnd(20)} ${CYAN}${spendStr}${RESET} ${DIM}${barStr}${RESET}`)); + } + } + } + + if (xai.total_usd != null && xai.prepaid_remaining_usd != null) { + lines.push(border(` ${hline('─', 44)}`)); + const remaining = xai.prepaid_remaining_usd; + const remainColor = remaining < 5 ? RED : remaining < 15 ? YELLOW : GREEN; + lines.push(border(` ${DIM}Prepaid left:${RESET} ${remainColor}$${remaining.toFixed(2)}${RESET} ${DIM}period: ${xai.period || '?'}${RESET}`)); + } + + return lines; +} + +function renderDecision(decision, lastProbe) { + const lines = []; + lines.push(border('')); + + if (decision?.recommended_provider) { + lines.push(border(` ${GREEN}→ USE:${RESET} ${BOLD}${decision.recommended_provider}${RESET}`)); + } + if (decision?.avoid?.length > 0) { + lines.push(border(` ${RED}→ AVOID:${RESET} ${decision.avoid.join(', ')}`)); + } + + const probeStr = lastProbe ? `Last probe: ${new Date(lastProbe).toUTCString().slice(17, 25)} UTC` : ''; + if (probeStr) { + lines.push(border(` ${DIM}${probeStr}${RESET}`)); + } + + return lines; +} + +// ── Render full screen ──────────────────────────────────────────────────────── + +function render(data, status) { + const out = []; + out.push(CURSOR_HOME); + + if (data?.error) { + out.push(header(null)); + out.push(border('')); + out.push(border(` ${RED}Error fetching data:${RESET} ${data.error}`)); + out.push(border('')); + out.push(border(` ${DIM}${status}${RESET}`)); + out.push(border('')); + out.push(footer()); + process.stdout.write(out.join('\n') + '\n'); + return; + } + + const hasXai = data?.xai && !data.xai.error && process.env.XAI_MANAGEMENT_KEY; + + out.push(header(data?.ts)); + out.push(border('')); + + // Two-column layout: Anthropic left, xAI right + const anthLines = renderAnthropicSection(data?.providers, data?.budget_decision); + const xaiLines = hasXai ? renderXaiSection(data?.xai) : []; + + // Output sections + const maxRows = Math.max(anthLines.length, xaiLines.length); + + if (hasXai) { + // Render each section separately (full-width) + out.push(border(`${BOLD}${CYAN}ANTHROPIC TEAMS${RESET}${''.padEnd(8)}${BOLD}${CYAN}xAI${RESET}`)); + out.push(border(hline('─', WIDTH - 2))); + // Side-by-side isn't trivial with ANSI — render stacked instead + for (const line of anthLines.slice(1)) out.push(line); + out.push(border('')); + for (const line of xaiLines) out.push(line); + } else { + for (const line of anthLines) out.push(line); + } + + // Decision + last probe + const decisionLines = renderDecision(data?.budget_decision, data?.ts); + for (const line of decisionLines) out.push(line); + + // Alerts + if (data?.alerts?.length > 0) { + out.push(border('')); + out.push(border(` ${YELLOW}${BOLD}Alerts:${RESET}`)); + for (const alert of data.alerts.slice(0, 3)) { + const c = alert.level === 'critical' ? RED : YELLOW; + out.push(border(` ${c}⚠${RESET} ${alert.provider}: ${alert.message}`)); + } + } + + out.push(border('')); + out.push(border(` ${DIM}${status}${RESET}`)); + out.push(border('')); + out.push(footer()); + + process.stdout.write(out.join('\n') + '\n'); +} + +// ── Main loop ───────────────────────────────────────────────────────────────── + +let lastData = null; +let refreshTimer = null; +let isRefreshing = false; + +function statusLine(nextRefreshIn) { + const next = nextRefreshIn > 0 ? `next refresh in ${nextRefreshIn}s` : 'refreshing...'; + return next; +} + +async function refresh() { + if (isRefreshing) return; + isRefreshing = true; + render(lastData, 'refreshing...'); + const data = await fetchBudgetData(); + lastData = data; + isRefreshing = false; + scheduleRefresh(); + render(lastData, statusLine(60)); +} + +let countdown = 60; + +function scheduleRefresh() { + if (refreshTimer) clearInterval(refreshTimer); + countdown = 60; + refreshTimer = setInterval(() => { + countdown--; + if (!isRefreshing) render(lastData, statusLine(countdown)); + if (countdown <= 0) { + clearInterval(refreshTimer); + refresh(); + } + }, 1000); +} + +function cleanup() { + if (refreshTimer) clearInterval(refreshTimer); + process.stdout.write(SHOW_CURSOR); + process.stdout.write('\n'); + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); +} + +function start() { + // Hide cursor, clear screen + process.stdout.write(HIDE_CURSOR); + process.stdout.write(CLEAR_SCREEN); + + // Keyboard input + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (key) => { + if (key === 'q' || key === '\u0003') { // q or Ctrl-C + cleanup(); + process.exit(0); + } + if (key === 'r') { + if (refreshTimer) clearInterval(refreshTimer); + refresh(); + } + }); + + process.on('SIGINT', () => { cleanup(); process.exit(0); }); + process.on('SIGTERM', () => { cleanup(); process.exit(0); }); + + // Initial render placeholder + render({ error: null }, 'initializing...'); + + // First fetch + refresh(); +} + +start();