Merge origin/main into wallet-integration

This commit is contained in:
Evan Yang
2026-03-23 10:43:48 +08:00
24 changed files with 1746 additions and 26 deletions
+2 -3
View File
@@ -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=="],
+6 -4
View File
@@ -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",
+113
View File
@@ -0,0 +1,113 @@
# TUI refactor plan
## Goals
- Move the usage TUI implementation out of `src/cli/usage-tui.ts` into a dedicated `src/tui/` folder.
- Reduce the size and responsibility of the current monolithic file.
- Keep the existing CLI entrypoint stable so current usage does not break.
- Preserve behavior while making future TUI work easier.
## Current state
`src/cli/usage-tui.ts` currently mixes several concerns in one file:
- TUI-specific types and constants
- ANSI/terminal helpers
- scroll/search/vim navigation state
- data fetching from the daemon
- usage aggregation/stat helpers
- rendering for all tabs
- app lifecycle and keyboard event handling
This makes the file hard to extend safely.
## Refactor strategy
Do this incrementally and keep a thin compatibility wrapper in `src/cli/usage-tui.ts`.
### Target structure
- `src/tui/usage/index.ts`
- public entrypoint: `runUsageTui()`
- `src/tui/usage/types.ts`
- `UsageStats`, tab ids, tab metadata, derived stat types
- `src/tui/usage/constants.ts`
- tabs, colors, model/client color maps
- `src/tui/usage/terminal.ts`
- ANSI helpers, width/height helpers, `stripAnsi`
- `src/tui/usage/state.ts`
- vim/search/scroll state and state mutation helpers
- `src/tui/usage/data.ts`
- `fetchUsage()` and usage aggregation helpers
- `src/tui/usage/render.ts`
- shared render helpers and tab renderers
- `src/tui/usage/app.ts`
- main loop, render orchestration, input handling, cleanup
- `src/cli/usage-tui.ts`
- compatibility wrapper that re-exports or calls `runUsageTui()` from `src/tui/usage`
## Design choices
### 1. Keep CLI path compatibility
Do not delete the CLI file outright. Turn it into a tiny wrapper:
- minimal import from `../tui/usage/index.ts`
- export `runUsageTui()`
This avoids breaking any existing imports or scripts.
### 2. Separate pure logic from side effects
Keep these pure where possible:
- aggregation helpers
- formatting helpers
- render helpers that return strings
- scroll clamping logic
Keep side effects isolated in the app layer:
- reading terminal size
- writing to stdout
- raw mode setup
- signal handling
- interval scheduling
### 3. Avoid over-engineering
This should be a pragmatic refactor, not a framework:
- no unnecessary classes
- keep function-based design
- only extract modules around clear responsibility boundaries
### 4. Preserve behavior first
No UX changes unless needed to support the extraction.
That means:
- same tabs
- same keybindings
- same output format
- same fetch cadence
- same search/scroll behavior
## Implementation steps
1. Create `src/tui/usage/`.
2. Extract types/constants first.
3. Extract terminal helpers.
4. Extract data fetching + aggregation helpers.
5. Extract state/search/scroll logic.
6. Extract rendering helpers + tab renderers.
7. Build `app.ts` using the extracted modules.
8. Replace `src/cli/usage-tui.ts` with a thin wrapper.
9. Run a TypeScript/bun check and fix imports.
10. Smoke-test keyboard handling and rendering behavior.
## Risks
- circular imports between render/state/constants
- broken relative import paths during extraction
- subtle behavior regressions in scroll/search state
- terminal escape handling differences if helpers are split carelessly
## Validation checklist
- `src/cli/usage-tui.ts` still exposes `runUsageTui()`
- TUI starts from the same CLI path
- scroll still works for long content
- vim keys still work
- arrow keys still work
- tab switching still resets scroll
- search mode still works
- cleanup still restores cursor and alternate screen
## Non-goals
- redesigning the UI
- changing tab contents
- introducing tests unless needed for safety
- adding new features unrelated to the refactor
+1 -1
View File
@@ -59,7 +59,7 @@ export async function startDaemonProcess(): Promise<void> {
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,
+130 -2
View File
@@ -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<void> {
}
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>", "Port to listen on")
.option("-p, --provider <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<number>((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")
+1
View File
@@ -0,0 +1 @@
export { runUsageTui } from "../tui/usage/index.ts";
+48
View File
@@ -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<string, number>;
mode: ClientMode;
error?: string;
};
@@ -38,8 +41,39 @@ type DaemonDeps = {
modelManager: any;
ensureProvidersBootstrapped: () => Promise<void>;
getRoutstr21Models: (forceRefresh?: boolean) => Promise<any[]>;
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<string> {
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<WalletStatusOutput> {
const mode = getCurrentMode(deps);
try {
const walletState = await deps.walletClient.getStatus();
if (walletState !== "UNLOCKED") {
@@ -165,6 +206,7 @@ async function buildStatusOutput(deps: DaemonDeps): Promise<WalletStatusOutput>
daemon: "running",
wallet: "error",
walletState,
mode,
error: getWalletStateMessage(walletState),
};
}
@@ -175,12 +217,14 @@ async function buildStatusOutput(deps: DaemonDeps): Promise<WalletStatusOutput>
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,
});
}
+2 -3
View File
@@ -34,9 +34,7 @@ async function main(): Promise<void> {
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<void> {
modelManager,
ensureProvidersBootstrapped,
getRoutstr21Models,
mode: config.mode || "apikeys",
}),
);
+8 -4
View File
@@ -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<string> {
process.stdout.write(question);
@@ -34,7 +35,10 @@ function parseChoice(input: string): number {
return 1;
}
export async function setupIntegration(config: RoutstrdConfig): Promise<void> {
export async function setupIntegration(
config: RoutstrdConfig,
store: SdkStore,
): Promise<void> {
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<void> {
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;
}
+39 -2
View File
@@ -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<void> {
function generateApiKey(): string {
const bytes = randomBytes(24);
return `sk-${bytes.toString("hex")}`;
}
export async function installOpenClawIntegration(
config: RoutstrdConfig,
store: SdkStore,
): Promise<void> {
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,
};
+39 -2
View File
@@ -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<void> {
function generateApiKey(): string {
const bytes = randomBytes(24);
return `sk-${bytes.toString("hex")}`;
}
export async function installOpencodeIntegration(
config: RoutstrdConfig,
store: SdkStore,
): Promise<void> {
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<string, {
npm?: string;
@@ -68,7 +105,7 @@ export async function installOpencodeIntegration(config: RoutstrdConfig): Promis
name: "routstr",
options: {
baseURL: `http://localhost:${port}/`,
apiKey: "",
apiKey,
includeUsage: true,
},
models: modelsObj,
+39 -5
View File
@@ -1,10 +1,14 @@
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 PI_CONFIG_PATH = join(process.env.HOME || "", ".pi/agent/models.json");
const PI_CLIENT_ID = "pi-agent";
const PI_NAME = "Pi Agent";
type RoutstrModel = {
id: string;
@@ -26,12 +30,45 @@ type PiConfig = {
providers?: Record<string, PiProviderConfig>;
};
export async function installPiIntegration(config: RoutstrdConfig): Promise<void> {
function generateApiKey(): string {
const bytes = randomBytes(24);
return `sk-${bytes.toString("hex")}`;
}
export async function installPiIntegration(
config: RoutstrdConfig,
store: SdkStore,
): Promise<void> {
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<void
id: model.id,
}));
// Preserve existing apiKey if present
const existingApiKey = piConfig.providers["routstr"]?.apiKey;
piConfig.providers["routstr"] = {
baseUrl,
api: "openai-completions",
apiKey: existingApiKey || "placeholder",
apiKey,
models: providerModels,
};
+247
View File
@@ -0,0 +1,247 @@
import { TABS } from "./constants.ts";
import { fetchBalance, fetchStatus, fetchUsage, type BalanceInfo, type StatusInfo } from "./data.ts";
import {
applyScrollToContent,
exitSearchMode,
nextSearchResult,
pageDown,
pageUp,
performSearch,
prevSearchResult,
scrollDown,
scrollToBottom,
scrollToTop,
scrollUp,
startSearch,
vimState,
} from "./state.ts";
import {
enterAlternateScreen,
eraseDown,
getHeight,
getWidth,
hideCursor,
leaveAlternateScreen,
moveCursor,
showCursor,
} from "./terminal.ts";
import { COLORS } from "./constants.ts";
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
import type { TabId, UsageStats } from "./types.ts";
import { isDaemonRunning } from "../../cli-shared.ts";
export async function runUsageTui(): Promise<void> {
const running = await isDaemonRunning();
if (!running) {
console.log(`${COLORS.red}Error: routstrd daemon is not running.${COLORS.reset}`);
console.log(`Run ${COLORS.green}routstrd start${COLORS.reset} first.`);
process.exit(1);
}
const stdin = process.stdin;
const stdout = process.stdout;
const isInteractive = Boolean(stdout.isTTY && stdin.isTTY);
let currentTab: TabId = "overview";
let stats: UsageStats | null = null;
let balance: BalanceInfo | null = null;
let status: StatusInfo | null = null;
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let shouldUpdate = true;
let autoRefresh = true;
let cleanedUp = false;
let rendering = false;
if (isInteractive) {
stdout.write(enterAlternateScreen() + hideCursor());
stdin.setRawMode?.(true);
stdin.resume();
stdin.setEncoding("utf-8");
}
function cleanup(exitCode = 0) {
if (cleanedUp) return;
cleanedUp = true;
if (refreshInterval) clearInterval(refreshInterval);
if (isInteractive) {
stdin.setRawMode?.(false);
stdin.pause();
stdout.write(showCursor() + leaveAlternateScreen());
} else {
stdout.write(showCursor());
}
process.exit(exitCode);
}
process.on("SIGINT", () => cleanup(0));
process.on("SIGTERM", () => cleanup(0));
async function render(forceFetch = false) {
if (rendering) return;
rendering = true;
try {
const width = getWidth();
const height = getHeight();
if (forceFetch || shouldUpdate) {
stats = await fetchUsage(1000);
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);
}
+42
View File
@@ -0,0 +1,42 @@
import type { Tab } from "./types.ts";
export const TABS: Tab[] = [
{ id: "overview", name: "Overview", key: "1" },
{ id: "today", name: "Today", key: "2" },
{ id: "models", name: "Models", key: "3" },
{ id: "providers", name: "Providers", key: "4" },
{ id: "tokens", name: "Tokens", key: "5" },
{ id: "clients", name: "Clients", key: "6" },
{ id: "recent", name: "Recent", key: "7" },
];
export const COLORS = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
bgBlue: "\x1b[44m",
bgGreen: "\x1b[42m",
bgYellow: "\x1b[43m",
bright: "\x1b[1m",
};
export const MODEL_COLORS: Record<string, string> = {
"gpt-5.4": COLORS.magenta,
"minimax-m2.7": COLORS.cyan,
default: COLORS.white,
};
export const CLIENT_COLORS: Record<string, string> = {
opencode: COLORS.blue,
openclaw: COLORS.green,
"pi-agent": COLORS.yellow,
unknown: COLORS.dim,
default: COLORS.white,
};
+228
View File
@@ -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<StatusInfo | null> {
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<BalanceInfo | null> {
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<UsageStats | null> {
try {
const running = await isDaemonRunning();
if (!running) return null;
const result = await callDaemon(`/usage?limit=${limit}`);
if (result.error) return null;
const output = result.output as {
entries?: UsageTrackingEntry[];
totalEntries?: number;
totalSatsCost?: number;
recentSatsCost?: number;
limit?: number;
};
return {
entries: output?.entries || [],
totalEntries: output?.totalEntries || 0,
totalSatsCost: output?.totalSatsCost || 0,
recentSatsCost: output?.recentSatsCost || 0,
limit: output?.limit || limit,
};
} catch {
return null;
}
}
export function getTodayStart(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
export function formatDate(timestamp: number): string {
return new Date(timestamp).toISOString().split("T")[0] ?? "";
}
export function formatTime(timestamp: number): string {
return new Date(timestamp).toISOString().split("T")[1]?.slice(0, 8) ?? "";
}
export function formatNumber(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return n.toString();
}
export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
const days = new Map<string, DayStats>();
for (const entry of entries) {
const date = formatDate(entry.timestamp);
const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
days.set(date, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return days;
}
export function getHourlyToday(entries: UsageTrackingEntry[]): Map<number, DayStats> {
const todayStart = getTodayStart();
const hours = new Map<number, DayStats>();
for (const entry of entries) {
if (entry.timestamp < todayStart) continue;
const hour = new Date(entry.timestamp).getHours();
const existing = hours.get(hour) || {
date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
hours.set(hour, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return hours;
}
export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] {
const models = new Map<string, ModelStats>();
for (const entry of entries) {
const existing = models.get(entry.modelId) || {
modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
models.set(entry.modelId, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] {
const providers = new Map<string, ProviderStats>();
for (const entry of entries) {
const url = entry.baseUrl || "unknown";
const existing = providers.get(url) || {
baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
providers.set(url, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
const clients = new Map<string, ClientStats>();
for (const entry of entries) {
const client = entry.client || "unknown";
const existing = clients.get(client) || {
client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
};
clients.set(client, {
...existing,
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
promptTokens: existing.promptTokens + entry.promptTokens,
completionTokens: existing.completionTokens + entry.completionTokens,
totalTokens: existing.totalTokens + entry.totalTokens,
});
}
return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
}
export function getTotals(entries: UsageTrackingEntry[]) {
return entries.reduce(
(acc, entry) => ({
promptTokens: acc.promptTokens + entry.promptTokens,
completionTokens: acc.completionTokens + entry.completionTokens,
totalTokens: acc.totalTokens + entry.totalTokens,
satsCost: acc.satsCost + entry.satsCost,
}),
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }
);
}
+1
View File
@@ -0,0 +1 @@
export { runUsageTui } from "./app.ts";
+539
View File
@@ -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<string, number>();
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<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
for (const entry of stats.entries) {
const client = entry.client || "unknown";
const model = entry.modelId;
if (!clientModelMap.has(client)) clientModelMap.set(client, new Map());
const modelMap = clientModelMap.get(client)!;
const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
modelMap.set(model, {
requests: existing.requests + 1,
satsCost: existing.satsCost + entry.satsCost,
tokens: existing.tokens + entry.totalTokens,
});
}
const clientModelLines: string[] = [];
for (const topClient of clientStats.slice(0, 3)) {
const modelMap = clientModelMap.get(topClient.client);
if (!modelMap) continue;
const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${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";
}
}
+100
View File
@@ -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";
}
+39
View File
@@ -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, "");
}
+65
View File
@@ -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;
}
+2
View File
@@ -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",
};
+15
View File
@@ -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}`));
+17
View File
@@ -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);
+23
View File
@@ -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"));