diff --git a/bun.lock b/bun.lock index b22050c..6b9abcb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,9 @@ "name": "routstrd", "dependencies": { "@cashu/cashu-ts": "^3.1.1", - "@routstr/sdk": "^0.2.0", + "@routstr/sdk": "^0.2.2", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", - "better-sqlite3": "^11.7.2", "commander": "^14.0.2", "rxjs": "^7.8.1", "zustand": "^5.0.5", @@ -31,7 +30,7 @@ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], - "@routstr/sdk": ["@routstr/sdk@0.2.0", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "rxjs": "^7.8.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^11.7.2" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-h/8zPMTd4W6h2TdqUh/KXUYXDHQf/U644dd//++FofrMSTYGxHzcW1bS2aDm4ij/RbZOBF0BP2I1YAr5erFYpw=="], + "@routstr/sdk": ["@routstr/sdk@0.2.2", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "rxjs": "^7.8.1", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^11.7.2" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-fu3cFUnBu8Lzxqrr0G3cPDxs86bFl1VmzizTRVBYV4RlNyD3kh167pVra823a6zYnve+3TqQGgXV9Uif8jJDjw=="], "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], diff --git a/package.json b/package.json index f36276a..3ba4e0b 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,15 @@ "type": "module", "private": false, "bin": { - "routstrd": "./src/index.ts" + "routstrd": "./dist/index.js" }, "scripts": { "start": "bun src/index.ts start", + "monitor": "bun src/index.ts monitor", "lint": "tsc --noEmit", - "test": "bun test" + "test": "bun test", + "build": "bun build src/index.ts --target=bun --outfile=dist/index.js", + "prepublishOnly": "bun run build" }, "devDependencies": { "@types/bun": "latest", @@ -21,10 +24,9 @@ }, "dependencies": { "@cashu/cashu-ts": "^3.1.1", - "@routstr/sdk": "^0.2.0", + "@routstr/sdk": "^0.2.2", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", - "better-sqlite3": "^11.7.2", "commander": "^14.0.2", "qrcode": "^1.5.4", "rxjs": "^7.8.1", 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-shared.ts b/src/cli-shared.ts index 7389063..b0c7b46 100644 --- a/src/cli-shared.ts +++ b/src/cli-shared.ts @@ -59,7 +59,7 @@ export async function startDaemonProcess(): Promise { const logFile = Bun.file(LOG_FILE); const proc = Bun.spawn([ - "bun", "run", `${import.meta.dir}/index.ts`, "daemon" + "bun", "run", `${import.meta.dir}/daemon/index.ts` ], { stdout: logFile, stderr: logFile, diff --git a/src/cli.ts b/src/cli.ts index f15ab56..d072c54 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,6 +18,8 @@ import { } from "./utils/config"; import { logger } from "./utils/logger"; import { setupIntegration } from "./integrations"; +import { createSdkStore } from "@routstr/sdk"; +import { createBunSqliteDriver } from "./daemon/sqlite-driver"; import * as QRCode from "qrcode"; import { isCocodInstalled, @@ -42,6 +44,7 @@ type UsageEntry = { promptTokens: number; completionTokens: number; totalTokens: number; + client?: string; }; const cliVersion = "0.1.0"; @@ -164,7 +167,12 @@ async function initDaemon(): Promise { } await startDaemon({ port: String(config.port || 8008) }); - await setupIntegration(config); + + // Create SDK store for integrations + const sqliteDriver = createBunSqliteDriver(DB_PATH); + const { store } = await createSdkStore({ driver: sqliteDriver }); + + await setupIntegration(config, store); logger.log("\nInitialization complete!"); logger.log( @@ -375,8 +383,9 @@ program const time = new Date(entry.timestamp).toISOString(); const provider = entry.baseUrl || "unknown"; const reqId = entry.requestId || "unknown"; + const client = entry.client ? ` | client: ${entry.client}` : ""; console.log( - `${index + 1}. ${time} | ${entry.modelId} | ${provider} | ${entry.satsCost.toFixed(3)} sats`, + `${index + 1}. ${time} | ${entry.modelId} | ${provider} | ${entry.satsCost.toFixed(3)} sats${client}`, ); console.log( ` tokens p/c/t: ${entry.promptTokens}/${entry.completionTokens}/${entry.totalTokens} | request: ${reqId}`, @@ -384,6 +393,15 @@ program }); }); +// Monitor - interactive TUI +program + .command("monitor") + .description("Open interactive TUI for usage monitoring (htop-like)") + .action(async () => { + const { runUsageTui } = await import("./cli/usage-tui"); + await runUsageTui(); + }); + const walletCmd = program.command("wallet").description("Wallet operations"); walletCmd @@ -525,6 +543,116 @@ program await handleDaemonCommand("/stop", { method: "POST" }); }); +// Restart +program + .command("restart") + .description("Restart the background daemon") + .option("--port ", "Port to listen on") + .option("-p, --provider ", "Default provider to use") + .action(async (options: { port?: string; provider?: string }) => { + const config = await loadConfig(); + const wasRunning = await isDaemonRunning(); + + if (wasRunning) { + console.log("Stopping daemon..."); + await callDaemon("/stop", { method: "POST" }); + + // Wait for daemon to fully stop + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (!(await isDaemonRunning())) { + break; + } + } + + if (await isDaemonRunning()) { + logger.error("Daemon failed to stop within 5 seconds"); + process.exit(1); + } + console.log("Daemon stopped."); + } else { + console.log("Daemon was not running."); + } + + console.log("Starting daemon..."); + await startDaemon({ + port: options.port || String(config.port || 8008), + provider: options.provider, + }); + console.log("Daemon restarted."); + }); + +// Mode +program + .command("mode") + .description("Set the client mode (xcashu, lazyrefund, or apikeys)") + .action(async () => { + const config = await loadConfig(); + const currentMode = config.mode || "apikeys"; + + console.log("Select client mode:"); + console.log(" 1) apikeys - Pseudonymous accounts are kept with the Routstr nodes for easy topup and refunds."); + console.log(" 2) xcashu - Balances are never kept with the nodes, all balances are refunded in response."); + console.log(" 3) lazyrefund - Refunds are performed periodically which also means identities are reset periodically."); + console.log(`\nCurrent mode: ${currentMode}`); + + const modes: Array<"apikeys" | "xcashu" | "lazyrefund"> = ["apikeys", "xcashu", "lazyrefund"]; + + const selectedIndex = await new Promise((resolve) => { + const rl = require("readline").createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question("\nEnter choice (1-3): ", (answer: string) => { + rl.close(); + const num = parseInt(answer, 10); + resolve(Number.isFinite(num) && num >= 1 && num <= 3 ? num - 1 : 0); + }); + }); + + const selectedMode = modes[selectedIndex]; + + if (selectedMode === currentMode) { + console.log(`Mode is already set to '${selectedMode}'. No changes made.`); + return; + } + + // Update config + const updatedConfig: RoutstrdConfig = { + ...config, + mode: selectedMode, + }; + await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); + console.log(`Mode set to '${selectedMode}'. Restarting daemon...`); + + // Restart daemon + const wasRunning = await isDaemonRunning(); + if (wasRunning) { + console.log("Stopping daemon..."); + await callDaemon("/stop", { method: "POST" }); + + for (let i = 0; i < 50; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (!(await isDaemonRunning())) { + break; + } + } + + if (await isDaemonRunning()) { + logger.error("Daemon failed to stop within 5 seconds"); + process.exit(1); + } + console.log("Daemon stopped."); + } + + console.log("Starting daemon..."); + await startDaemon({ + port: String(config.port || 8008), + provider: config.provider || undefined, + }); + console.log(`Daemon restarted with mode '${selectedMode}'.`); + }); + // Logs program .command("logs") diff --git a/src/cli/usage-tui.ts b/src/cli/usage-tui.ts new file mode 100644 index 0000000..8256740 --- /dev/null +++ b/src/cli/usage-tui.ts @@ -0,0 +1 @@ +export { runUsageTui } from "../tui/usage/index.ts"; diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 020a6c6..9e3f67c 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -18,11 +18,14 @@ import { } from "../wallet/cocod-client"; import { decodeCashuTokenAmount } from "../wallet"; +type ClientMode = "xcashu" | "lazyrefund" | "apikeys"; + type WalletStatusOutput = { daemon: "running"; wallet: "connected" | "error"; walletState: CocodState; balances?: Record; + mode: ClientMode; error?: string; }; @@ -38,8 +41,39 @@ type DaemonDeps = { modelManager: any; ensureProvidersBootstrapped: () => Promise; getRoutstr21Models: (forceRefresh?: boolean) => Promise; + mode?: ClientMode; }; +/** + * Extracts the client ID from an incoming request by looking up the API key + * in the store's clientIds list. + */ +function getClientIdFromRequest( + req: IncomingMessage, + store: { getState(): any }, +): string | undefined { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return undefined; + } + + const apiKey = authHeader.slice(7); // Remove "Bearer " prefix + + if (!apiKey.startsWith("sk-")) { + return undefined; + } + + const state = store.getState(); + const clientIds = state.clientIds || []; + + const matchingClient = (clientIds as { clientId: string; apiKey: string }[]).find( + (c) => c.apiKey === apiKey, + ); + + return matchingClient?.clientId; +} + async function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let data = ""; @@ -157,7 +191,14 @@ function optionalStringField( return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function getCurrentMode(deps: DaemonDeps): ClientMode { + const stateMode = deps.store.getState()?.mode; + return stateMode || deps.mode || "apikeys"; +} + async function buildStatusOutput(deps: DaemonDeps): Promise { + const mode = getCurrentMode(deps); + try { const walletState = await deps.walletClient.getStatus(); if (walletState !== "UNLOCKED") { @@ -165,6 +206,7 @@ async function buildStatusOutput(deps: DaemonDeps): Promise daemon: "running", wallet: "error", walletState, + mode, error: getWalletStateMessage(walletState), }; } @@ -175,12 +217,14 @@ async function buildStatusOutput(deps: DaemonDeps): Promise wallet: "connected", walletState, balances, + mode, }; } catch (error) { return { daemon: "running", wallet: "error", walletState: "ERROR", + mode, error: toErrorMessage(error), }; } @@ -582,6 +626,7 @@ export function createDaemonRequestHandler(deps: DaemonDeps) { discoveryAdapter: deps.discoveryAdapter, modelManager: deps.modelManager, debugLevel: "DEBUG", + mode: deps.mode, }); const isStream = bodyObj.stream === true; @@ -615,6 +660,7 @@ export function createDaemonRequestHandler(deps: DaemonDeps) { res.on("finish", () => { if (capturedUsage) { const usageRequestId = capturedResponseId || requestId || "unknown"; + logger.log(req); usageTracker.append({ id: usageRequestId === "unknown" @@ -624,6 +670,7 @@ export function createDaemonRequestHandler(deps: DaemonDeps) { modelId, baseUrl: usageBaseUrl, requestId: usageRequestId, + client: getClientIdFromRequest(req, deps.store), ...capturedUsage, }); logger.log( @@ -652,6 +699,7 @@ export function createDaemonRequestHandler(deps: DaemonDeps) { modelId, baseUrl: usageBaseUrl, requestId: responseRequestId, + client: getClientIdFromRequest(req, deps.store), ...nonStreamUsage, }); } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index fa1038e..ee411e4 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -34,9 +34,7 @@ async function main(): Promise { saveDaemonConfig(updatedConfig); const sqliteDriver = createBunSqliteDriver(DB_PATH); - const sdkStore = createSdkStore({ driver: sqliteDriver }); - await sdkStore.hydrate; - const store = sdkStore.store; + const { store } = await createSdkStore({ driver: sqliteDriver }); const discoveryAdapter = createDiscoveryAdapterFromStore(store); const providerRegistry = createProviderRegistryFromStore(store); @@ -66,6 +64,7 @@ async function main(): Promise { modelManager, ensureProvidersBootstrapped, getRoutstr21Models, + mode: config.mode || "apikeys", }), ); diff --git a/src/integrations/index.ts b/src/integrations/index.ts index 6c8896f..22ebacf 100644 --- a/src/integrations/index.ts +++ b/src/integrations/index.ts @@ -3,6 +3,7 @@ import { logger } from "../utils/logger"; import { installOpencodeIntegration } from "./opencode"; import { installOpenClawIntegration } from "./openclaw"; import { installPiIntegration } from "./pi"; +import type { SdkStore } from "@routstr/sdk"; function ask(question: string): Promise { process.stdout.write(question); @@ -34,7 +35,10 @@ function parseChoice(input: string): number { return 1; } -export async function setupIntegration(config: RoutstrdConfig): Promise { +export async function setupIntegration( + config: RoutstrdConfig, + store: SdkStore, +): Promise { logger.log("\nChoose an integration to set up:"); logger.log("1. OpenCode (default)"); logger.log("2. OpenClaw"); @@ -45,17 +49,17 @@ export async function setupIntegration(config: RoutstrdConfig): Promise { const choice = parseChoice(answer); if (choice === 1) { - await installOpencodeIntegration(config); + await installOpencodeIntegration(config, store); return; } if (choice === 2) { - await installOpenClawIntegration(config); + await installOpenClawIntegration(config, store); return; } if (choice === 3) { - await installPiIntegration(config); + await installPiIntegration(config, store); return; } diff --git a/src/integrations/openclaw.ts b/src/integrations/openclaw.ts index 64fc30c..4560d3a 100644 --- a/src/integrations/openclaw.ts +++ b/src/integrations/openclaw.ts @@ -1,14 +1,18 @@ +import { randomBytes } from "crypto"; import { existsSync, mkdirSync } from "fs"; import { readFile, writeFile } from "fs/promises"; import { dirname, join } from "path"; import type { RoutstrdConfig } from "../utils/config"; import { logger } from "../utils/logger"; +import type { SdkStore } from "@routstr/sdk"; const OPENCLAW_CONFIG_PATH = join(process.env.HOME || "", ".openclaw/openclaw.json"); const OPENCLAW_PROVIDER_ID = "routstr"; const OPENCLAW_API_BASE = "http://localhost:8008/v1"; const OPENCLAW_DEFAULT_PRIMARY_MODEL = "routstr/minimax-m2.5"; const OPENCLAW_DEFAULT_FALLBACK_MODEL = "routstr/kimi-k2.5"; +const OPENCLAW_CLIENT_ID = "openclaw"; +const OPENCLAW_NAME = "OpenClaw"; type RoutstrModel = { id: string; @@ -54,11 +58,44 @@ function toAlias(modelId: string): string { return modelId; } -export async function installOpenClawIntegration(config: RoutstrdConfig): Promise { +function generateApiKey(): string { + const bytes = randomBytes(24); + return `sk-${bytes.toString("hex")}`; +} + +export async function installOpenClawIntegration( + config: RoutstrdConfig, + store: SdkStore, +): Promise { logger.log("\nInstalling routstr models in openclaw.json..."); const port = config.port || 8008; + // Get or create clientId entry for OpenClaw + const state = store.getState(); + const existingClient = (state.clientIds || []).find( + (c: { clientId: string }) => c.clientId === OPENCLAW_CLIENT_ID, + ); + + let apiKey: string; + if (existingClient) { + apiKey = existingClient.apiKey; + logger.log(`Using existing API key for ${OPENCLAW_NAME}`); + } else { + apiKey = generateApiKey(); + // Add new clientId entry using proper store action + store.getState().setClientIds((prev) => [ + ...(prev || []), + { + clientId: OPENCLAW_CLIENT_ID, + name: OPENCLAW_NAME, + apiKey, + createdAt: Date.now(), + }, + ]); + logger.log(`Created new API key for ${OPENCLAW_NAME}`); + } + let openclawConfig: OpenClawConfig = {}; try { @@ -103,7 +140,7 @@ export async function installOpenClawIntegration(config: RoutstrdConfig): Promis openclawConfig.models.providers[OPENCLAW_PROVIDER_ID] = { baseUrl: OPENCLAW_API_BASE, - apiKey: openclawConfig.models.providers[OPENCLAW_PROVIDER_ID]?.apiKey || "placehodler", + apiKey, api: "openai-completions", models: providerModels, }; diff --git a/src/integrations/opencode.ts b/src/integrations/opencode.ts index 407b3cf..0add518 100644 --- a/src/integrations/opencode.ts +++ b/src/integrations/opencode.ts @@ -1,22 +1,59 @@ +import { randomBytes } from "crypto"; import { existsSync, mkdirSync } from "fs"; import { readFile, writeFile } from "fs/promises"; import { dirname, join } from "path"; import type { RoutstrdConfig } from "../utils/config"; import { logger } from "../utils/logger"; +import type { SdkStore } from "@routstr/sdk"; const OPENCODE_CONFIG_PATH = join(process.env.HOME || "", ".config/opencode/opencode.json"); const OPENCODE_SMALL_MODEL = "routstr/minimax-m2.5"; +const OPENCODE_CLIENT_ID = "opencode"; +const OPENCODE_NAME = "OpenCode"; type RoutstrModel = { id: string; name?: string; }; -export async function installOpencodeIntegration(config: RoutstrdConfig): Promise { +function generateApiKey(): string { + const bytes = randomBytes(24); + return `sk-${bytes.toString("hex")}`; +} + +export async function installOpencodeIntegration( + config: RoutstrdConfig, + store: SdkStore, +): Promise { logger.log("\nInstalling routstr models in opencode.json..."); const port = config.port || 8008; + // Get or create clientId entry for OpenCode + const state = store.getState(); + const existingClient = (state.clientIds || []).find( + (c: { clientId: string }) => c.clientId === OPENCODE_CLIENT_ID, + ); + + let apiKey: string; + if (existingClient) { + apiKey = existingClient.apiKey; + logger.log(`Using existing API key for ${OPENCODE_NAME}`); + } else { + apiKey = generateApiKey(); + // Add new clientId entry using proper store action + store.getState().setClientIds((prev) => [ + ...(prev || []), + { + clientId: OPENCODE_CLIENT_ID, + name: OPENCODE_NAME, + apiKey, + createdAt: Date.now(), + }, + ]); + logger.log(`Created new API key for ${OPENCODE_NAME}`); + } + let opencodeConfig: { provider?: Record; }; -export async function installPiIntegration(config: RoutstrdConfig): Promise { +function generateApiKey(): string { + const bytes = randomBytes(24); + return `sk-${bytes.toString("hex")}`; +} + +export async function installPiIntegration( + config: RoutstrdConfig, + store: SdkStore, +): Promise { logger.log("\nInstalling routstr models in pi models.json..."); const port = config.port || 8008; const baseUrl = `http://localhost:${port}/v1`; + // Get or create clientId entry for Pi Agent + const state = store.getState(); + const existingClient = (state.clientIds || []).find( + (c: { clientId: string }) => c.clientId === PI_CLIENT_ID, + ); + + let apiKey: string; + if (existingClient) { + apiKey = existingClient.apiKey; + logger.log(`Using existing API key for ${PI_NAME}`); + } else { + apiKey = generateApiKey(); + // Add new clientId entry using proper store action + store.getState().setClientIds((prev) => [ + ...(prev || []), + { + clientId: PI_CLIENT_ID, + name: PI_NAME, + apiKey, + createdAt: Date.now(), + }, + ]); + logger.log(`Created new API key for ${PI_NAME}`); + } + let piConfig: PiConfig = {}; try { @@ -64,13 +101,10 @@ export async function installPiIntegration(config: RoutstrdConfig): 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 balance: BalanceInfo | null = null; + let status: StatusInfo | 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); + balance = await fetchBalance(); + status = await fetchStatus(); + 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, balance, status, 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..1028bb0 --- /dev/null +++ b/src/tui/usage/data.ts @@ -0,0 +1,228 @@ +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 interface BalanceKey { + id: string; + name: string; + balance: number; +} + +export interface BalanceInfo { + keys: BalanceKey[]; + total: number; + unit: "sat"; + apikeysCalled: number; +} + +export interface StatusInfo { + daemon: string; + wallet: string; + mode: "xcashu" | "lazyrefund" | "apikeys"; + error?: string; +} + +export async function fetchStatus(): Promise { + try { + const running = await isDaemonRunning(); + if (!running) return null; + + const result = await callDaemon("/status"); + if (result.error) return null; + + const output = result.output as { + daemon?: string; + wallet?: string; + mode?: "xcashu" | "lazyrefund" | "apikeys"; + error?: string; + }; + + return { + daemon: output?.daemon || "unknown", + wallet: output?.wallet || "unknown", + mode: output?.mode || "apikeys", + error: output?.error, + }; + } catch { + return null; + } +} + +export async function fetchBalance(): Promise { + try { + const running = await isDaemonRunning(); + if (!running) return null; + + const result = await callDaemon("/keys/balance"); + if (result.error) return null; + + const output = result.output as { + keys?: BalanceKey[]; + total?: number; + unit?: string; + apikeysCalled?: number; + }; + + return { + keys: output?.keys || [], + total: output?.total || 0, + unit: (output?.unit as "sat") || "sat", + apikeysCalled: output?.apikeysCalled || 0, + }; + } catch { + return null; + } +} + +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..6853ffc --- /dev/null +++ b/src/tui/usage/render.ts @@ -0,0 +1,539 @@ +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 { BalanceInfo, StatusInfo } from "./data.ts"; +import type { TabId, UsageStats } from "./types.ts"; + +/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */ +function formatCost(value: number): string { + if (value >= 1_000_000) return (value / 1_000_000).toFixed(2) + "m"; + if (value >= 1_000) return (value / 1_000).toFixed(2) + "k"; + return value.toFixed(2); +} + +/** Format request count: 1, 12, 123, 1.2k, 1.2m */ +function formatReqs(value: number): string { + if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + "m"; + if (value >= 1_000) return (value / 1_000).toFixed(1) + "k"; + return value.toString(); +} + +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 = Math.max(0, width - 4); + + if (title) { + const titleStr = ` ${title} `; + const dashCount = Math.max(0, width - 2 - titleStr.length - 1); + result.push(`┌─${titleStr}${"─".repeat(dashCount)}┐`); + } else { + result.push(`┌${"─".repeat(Math.max(0, width - 2))}┐`); + } + + for (const line of lines) { + const padding = Math.max(0, innerWidth - stripAnsi(line).length); + result.push(`│ ${line}${" ".repeat(padding)} │`); + } + result.push(`└${"─".repeat(Math.max(0, width - 2))}┘`); + return result.join("\n"); +} + +const _sectionMaxLabelLen = new Map(); + +export function startBarSection(sectionKey: string, maxLabelLen: number): void { + _sectionMaxLabelLen.set(sectionKey, maxLabelLen); +} + +export function endBarSection(_sectionKey: string): void { + // kept for API compat; no-op since we compute max per call now +} + +export function renderBarChart( + label: string, + value: number, + maxValue: number, + width: number, + color: string, + percentageValue?: number, + sectionKey?: string, +): string { + const safeMaxValue = Math.max(maxValue, 1); + const pct = percentageValue !== undefined + ? percentageValue.toFixed(1) + : ((value / safeMaxValue) * 100).toFixed(1); + const suffix = ` ${pct}%`; + + const maxLen = sectionKey ? (_sectionMaxLabelLen.get(sectionKey) ?? label.length) : label.length; + const paddedLabel = label.padEnd(maxLen); + const reserved = suffix.length + 1; + const maxBarWidth = Math.max(0, width - paddedLabel.length - reserved); + const barLen = Math.max(0, Math.round((value / safeMaxValue) * maxBarWidth)); + const bar = color + "█".repeat(barLen) + COLORS.reset; + return `${paddedLabel} ${bar}${suffix}`; +} + + +export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string { + const totals = getTotals(stats.entries); + const entryCount = stats.entries.length; + const totalVisibleCost = totals.satsCost; + const avgCost = entryCount > 0 ? totalVisibleCost / entryCount : 0; + const avgTokens = entryCount > 0 ? totals.totalTokens / entryCount : 0; + + const leftBox = [ + `${COLORS.bold}Total Spent:${COLORS.reset} ${COLORS.green}${formatCost(totalVisibleCost)} sats${COLORS.reset}`, + `${COLORS.bold}Total Requests:${COLORS.reset} ${formatReqs(entryCount)}`, + `${COLORS.bold}Avg Cost/Req:${COLORS.reset} ${formatCost(avgCost)} sats`, + ]; + + 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`, + ]; + + const halfWidth1 = Math.floor(width / 2); + const halfWidth2 = width - halfWidth1; + + const leftBoxStr = renderBox(leftBox, halfWidth1, "Stats of Sats"); + const rightBoxStr = renderBox(rightBox, halfWidth2, "Token Stats"); + + const leftLines = leftBoxStr.split("\n"); + const rightLines = rightBoxStr.split("\n"); + const maxLines = Math.max(leftLines.length, rightLines.length); + + const combinedLines: string[] = []; + for (let i = 0; i < maxLines; i++) { + const l = leftLines[i] || " ".repeat(halfWidth1); + const r = rightLines[i] || " ".repeat(halfWidth2); + combinedLines.push(l + r); + } + + let output = combinedLines.join("\n"); + + // Status and Balance boxes side by side + const statusLines: string[] = []; + if (status) { + const daemonColor = status.daemon === "running" ? COLORS.green : COLORS.red; + const walletColor = status.wallet === "connected" ? COLORS.green : COLORS.red; + const modeColor = COLORS.cyan; + statusLines.push(`${COLORS.bold}Daemon:${COLORS.reset} ${daemonColor}${status.daemon}${COLORS.reset}`); + statusLines.push(`${COLORS.bold}Wallet:${COLORS.reset} ${walletColor}${status.wallet}${COLORS.reset}`); + statusLines.push(`${COLORS.bold}Mode:${COLORS.reset} ${modeColor}${status.mode}${COLORS.reset}`); + if (status.error) { + statusLines.push(`${COLORS.bold}Error:${COLORS.reset} ${COLORS.red}${status.error}${COLORS.reset}`); + } + } else { + statusLines.push(`${COLORS.dim}Status unavailable${COLORS.reset}`); + } + + // Display all balances (wallet, cached tokens, API keys) if available + if (balance && balance.keys.length > 0) { + const balanceLines: string[] = []; + const totalBalance = balance.total; + + if (totalBalance > 0) { + balanceLines.push(`${COLORS.bold}Total Balance:${COLORS.reset} ${COLORS.green}${totalBalance.toLocaleString()} sat${COLORS.reset}`); + } else { + balanceLines.push(`${COLORS.bold}Total Balance:${COLORS.reset} ${COLORS.red}0 sat${COLORS.reset}`); + } + + for (const key of balance.keys) { + const color = key.id === "wallet" ? COLORS.green : COLORS.cyan; + if (key.id === "wallet") { + balanceLines.push(`${color}Wallet:${COLORS.reset} ${key.balance.toLocaleString()} sat`); + } else { + // Extract provider URL from name (e.g., "API Key: https://..." or "Cached: https://...") + const providerUrl = key.name.replace(/^(API Key|Cached):\s*/, ""); + const shortProvider = providerUrl.replace("https://", "").replace("http://", ""); + const label = key.id.startsWith("cached:") ? "Cached" : "API Key"; + balanceLines.push(`${color}${label}:${COLORS.reset} ${shortProvider} (${key.balance.toLocaleString()} sat)`); + } + } + + if (totalBalance === 0) { + balanceLines.push(`${COLORS.dim}No funds available${COLORS.reset}`); + } + + // Render status and balance boxes side by side + const balanceBoxStr = renderBox(balanceLines, halfWidth1, "Balance"); + const statusBoxStr = renderBox(statusLines, halfWidth2, "System Status"); + const balanceBoxLines = balanceBoxStr.split("\n"); + const statusBoxLines = statusBoxStr.split("\n"); + const statusBalanceMaxLines = Math.max(balanceBoxLines.length, statusBoxLines.length); + + const statusBalanceLines: string[] = []; + for (let i = 0; i < statusBalanceMaxLines; i++) { + const b = balanceBoxLines[i] || " ".repeat(halfWidth1); + const s = statusBoxLines[i] || " ".repeat(halfWidth2); + statusBalanceLines.push(b + s); + } + output = statusBalanceLines.join("\n") + "\n" + output; + } else if (balance && balance.keys.length === 0) { + // Only balance is empty, show status next to empty balance box + const balanceLines: string[] = [ + `${COLORS.bold}Total Balance:${COLORS.reset} ${COLORS.red}0 sat${COLORS.reset}`, + `${COLORS.dim}No funds available${COLORS.reset}`, + ]; + const balanceBoxStr = renderBox(balanceLines, halfWidth1, "Balance"); + const statusBoxStr = renderBox(statusLines, halfWidth2, "System Status"); + const balanceBoxLines = balanceBoxStr.split("\n"); + const statusBoxLines = statusBoxStr.split("\n"); + const statusBalanceMaxLines = Math.max(balanceBoxLines.length, statusBoxLines.length); + + const statusBalanceLines: string[] = []; + for (let i = 0; i < statusBalanceMaxLines; i++) { + const b = balanceBoxLines[i] || " ".repeat(halfWidth1); + const s = statusBoxLines[i] || " ".repeat(halfWidth2); + statusBalanceLines.push(b + s); + } + output = statusBalanceLines.join("\n") + "\n" + output; + } else { + // No balance data, just show status box full width + output = renderBox(statusLines, width, "System Status") + "\n" + output; + } + + const modelStats = getModelStats(stats.entries); + if (modelStats.length > 0) { + const maxCost = modelStats[0]!.satsCost; + const totalCost = Math.max(totalVisibleCost, 1); + const maxModelLabel = Math.max(...modelStats.slice(0, 5).map((m) => m.modelId.length)) + 1; + startBarSection("models", maxModelLabel); + 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, + (m.satsCost / totalCost) * 100, + "models", + )); + endBarSection("models"); + output += "\n" + renderBox(modelLines, width, "Top Models by Cost"); + } + + const clientStats = getClientStats(stats.entries); + if (clientStats.length > 0) { + const maxCost = clientStats[0]!.satsCost; + const totalCost = Math.max(totalVisibleCost, 1); + const maxClientLabel = Math.max(...clientStats.slice(0, 5).map((c) => c.client.length)) + 1; + startBarSection("clients", maxClientLabel); + 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, + (c.satsCost / totalCost) * 100, + "clients", + )); + endBarSection("clients"); + 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} ${formatReqs(todayStats.requests)}`, + `${COLORS.bold}Cost:${COLORS.reset} ${COLORS.green}${formatCost(todayStats.satsCost)} 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"); + + const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7); + if (days.length > 1) { + const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`); + output += "\n" + renderBox(dayLines, width, "Recent Days"); + } + + const hourLines: string[] = []; + const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1); + const totalTodayCost = Math.max(todayStats.satsCost, 1); + const hourLabels: string[] = []; + for (let h = currentHour; h >= 0; h--) { + const hStat = hourly.get(h); + const reqs = hStat?.requests || 0; + const cost = hStat?.satsCost || 0; + hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `); + } + const maxHourLabel = Math.max(...hourLabels.map((l) => l.length)); + startBarSection("hourly", maxHourLabel); + for (let i = currentHour; i >= 0; i--) { + const hStat = hourly.get(i); + const reqs = hStat?.requests || 0; + const cost = hStat?.satsCost || 0; + hourLines.push(renderBarChart( + hourLabels[currentHour - i]!, + cost, + maxHourCost, + width - 4, + i === currentHour ? COLORS.green : COLORS.cyan, + (cost / totalTodayCost) * 100, + "hourly", + )); + } + endBarSection("hourly"); + + output += "\n" + renderBox(hourLines.length > 0 ? hourLines : ["No activity today yet"], width, "Hourly Activity"); + + 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 = getTotals(stats.entries).satsCost; + const maxCost = modelStats[0]!.satsCost; + const maxModelLabel = Math.max(...modelStats.map((m) => m.modelId.length)); + const lines: string[] = []; + + startBarSection("model-detail", maxModelLabel); + 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} ${formatCost(model.satsCost)} sats (${pct}%)`); + lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(model.requests)}`); + lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(model.totalTokens)}`); + lines.push(` ${COLORS.dim}Avg:${COLORS.reset} ${formatCost(model.satsCost / model.requests)} sats/req`); + lines.push(` ${renderBarChart(" ", model.satsCost, maxCost, width - 6, color, Number(pct), "model-detail")}`); + lines.push(""); + } + endBarSection("model-detail"); + + 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} ${formatReqs(provider.requests)}`); + lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(provider.satsCost)} 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)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} 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 = getTotals(stats.entries).satsCost; + const maxCost = clientStats[0]!.satsCost; + const lines: string[] = []; + + const col1 = 20; // Client + const col2 = 12; // Requests + const col3 = 24; // Cost + const col4 = 12; // Tokens + + const hClient = "Client".padEnd(col1); + const hReqs = "Requests".padEnd(col2); + const hCost = "Cost".padEnd(col3); + const hTok = "Tokens".padEnd(col4); + lines.push(`${COLORS.bold}${hClient}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`); + lines.push(COLORS.dim + "─".repeat(Math.max(0, width - 4)) + COLORS.reset); + + startBarSection("client-detail", 20); // match col1 + 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 avgCostFormatted = formatCost(client.requests > 0 ? client.satsCost / client.requests : 0); + + const dClient = client.client.slice(0, col1 - 1).padEnd(col1); + const dReqs = formatReqs(client.requests).padEnd(col2); + const dCost = `${formatCost(client.satsCost)} sats (${pct}%)`.padEnd(col3); + const dTok = formatNumber(client.totalTokens).padEnd(col4); + const dAvg = `${avgCostFormatted} sats/req`; + + lines.push( + `${color}${COLORS.bold}${dClient}${COLORS.reset}` + + `${dReqs}` + + `${COLORS.green}${dCost}${COLORS.reset}` + + `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}` + ); + lines.push(` ${renderBarChart("", client.satsCost, maxCost, width - 6, color, Number(pct), "client-detail")}`); + lines.push(""); + } + endBarSection("client-detail"); + + 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} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} 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 ${formatCost(data.satsCost)} 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 = `${formatCost(entry.satsCost).padEnd(8)} 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, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string { + switch (activeTab) { + case "overview": return renderOverview(stats, balance, status, 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; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index acc27a8..e3519bd 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -11,10 +11,12 @@ export interface RoutstrdConfig { port: number; provider: string | null; cocodPath: string | null; + mode?: "xcashu" | "lazyrefund" | "apikeys"; } export const DEFAULT_CONFIG: RoutstrdConfig = { port: 8008, provider: null, cocodPath: null, + mode: "apikeys", }; diff --git a/test_box.ts b/test_box.ts new file mode 100644 index 0000000..0617cf2 --- /dev/null +++ b/test_box.ts @@ -0,0 +1,15 @@ +import { renderBox } from "./src/tui/usage/render.ts"; +import { stripAnsi } from "./src/tui/usage/terminal.ts"; + +const w = 40; +const testBox = renderBox(["Hello World", "Line 2"], w, "Title"); +console.log(testBox); +const lines = testBox.split("\n"); +lines.forEach((l, i) => console.log(`Line ${i} length: ${stripAnsi(l).length}`)); + +console.log("---"); + +const testBoxNoTitle = renderBox(["Hello World", "Line 2"], w); +console.log(testBoxNoTitle); +const lines2 = testBoxNoTitle.split("\n"); +lines2.forEach((l, i) => console.log(`Line ${i} length: ${stripAnsi(l).length}`)); diff --git a/test_split_box.ts b/test_split_box.ts new file mode 100644 index 0000000..538a7cc --- /dev/null +++ b/test_split_box.ts @@ -0,0 +1,17 @@ +import { renderBox } from "./src/tui/usage/render.ts"; + +const width = 80; +const halfWidth1 = Math.floor(width / 2); +const halfWidth2 = width - halfWidth1; + +const leftBox = ["Total Spent: 12.78k sats", "Total Requests: 1.0k"]; +const rightBox = ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"]; + +const leftBoxStr = renderBox(leftBox, halfWidth1, "Stats of Sats"); +const rightBoxStr = renderBox(rightBox, halfWidth2, "Token Stats"); + +const leftLines = leftBoxStr.split("\n"); +const rightLines = rightBoxStr.split("\n"); + +const combinedContent = leftLines.map((l, i) => l + (rightLines[i] || " ".repeat(halfWidth2))).join("\n"); +console.log(combinedContent); diff --git a/test_split_box2.ts b/test_split_box2.ts new file mode 100644 index 0000000..9b08c14 --- /dev/null +++ b/test_split_box2.ts @@ -0,0 +1,23 @@ +import { renderBox } from "./src/tui/usage/render.ts"; + +const width = 80; +const halfWidth1 = Math.floor(width / 2); +const halfWidth2 = width - halfWidth1; + +const leftBox = ["Total Spent: 12.78k sats", "Total Requests: 1.0k"]; +const rightBox = ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"]; + +const leftBoxStr = renderBox(leftBox, halfWidth1, "Stats of Sats"); +const rightBoxStr = renderBox(rightBox, halfWidth2, "Token Stats"); + +const leftLines = leftBoxStr.split("\n"); +const rightLines = rightBoxStr.split("\n"); + +const maxLines = Math.max(leftLines.length, rightLines.length); +const combinedLines: string[] = []; +for (let i = 0; i < maxLines; i++) { + const l = leftLines[i] || " ".repeat(Math.floor(width / 2)); + const r = rightLines[i] || " ".repeat(Math.ceil(width / 2)); + combinedLines.push(l + r); +} +console.log(combinedLines.join("\n"));