mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
Merge origin/main into wallet-integration
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { runUsageTui } from "../tui/usage/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<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
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Tab } from "./types.ts";
|
||||
|
||||
export const TABS: Tab[] = [
|
||||
{ id: "overview", name: "Overview", key: "1" },
|
||||
{ id: "today", name: "Today", key: "2" },
|
||||
{ id: "models", name: "Models", key: "3" },
|
||||
{ id: "providers", name: "Providers", key: "4" },
|
||||
{ id: "tokens", name: "Tokens", key: "5" },
|
||||
{ id: "clients", name: "Clients", key: "6" },
|
||||
{ id: "recent", name: "Recent", key: "7" },
|
||||
];
|
||||
|
||||
export const COLORS = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
white: "\x1b[37m",
|
||||
bgBlue: "\x1b[44m",
|
||||
bgGreen: "\x1b[42m",
|
||||
bgYellow: "\x1b[43m",
|
||||
bright: "\x1b[1m",
|
||||
};
|
||||
|
||||
export const MODEL_COLORS: Record<string, string> = {
|
||||
"gpt-5.4": COLORS.magenta,
|
||||
"minimax-m2.7": COLORS.cyan,
|
||||
default: COLORS.white,
|
||||
};
|
||||
|
||||
export const CLIENT_COLORS: Record<string, string> = {
|
||||
opencode: COLORS.blue,
|
||||
openclaw: COLORS.green,
|
||||
"pi-agent": COLORS.yellow,
|
||||
unknown: COLORS.dim,
|
||||
default: COLORS.white,
|
||||
};
|
||||
@@ -0,0 +1,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 }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { runUsageTui } from "./app.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<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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
||||
import type { VimState } from "./types.ts";
|
||||
|
||||
export const vimState: VimState = {
|
||||
scrollPos: 0,
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
currentSearchIdx: 0,
|
||||
isSearching: false,
|
||||
searchReverse: false,
|
||||
mode: "normal",
|
||||
lastKey: "",
|
||||
lastKeyTime: 0,
|
||||
};
|
||||
|
||||
let maxScrollLines = 0;
|
||||
|
||||
export function clampScrollPos(contentHeight: number, viewportHeight: number): void {
|
||||
const maxScroll = Math.max(0, contentHeight - viewportHeight);
|
||||
vimState.scrollPos = Math.max(0, Math.min(vimState.scrollPos, maxScroll));
|
||||
}
|
||||
|
||||
export function applyScrollToContent(content: string, viewportHeight: number): string {
|
||||
const lines = content.split("\n");
|
||||
maxScrollLines = Math.max(0, lines.length - Math.max(0, viewportHeight));
|
||||
clampScrollPos(lines.length, viewportHeight);
|
||||
|
||||
if (viewportHeight <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return lines.slice(vimState.scrollPos, vimState.scrollPos + viewportHeight).join("\n");
|
||||
}
|
||||
|
||||
export function scrollDown(lines = 1): void {
|
||||
vimState.scrollPos = Math.min(vimState.scrollPos + lines, maxScrollLines);
|
||||
}
|
||||
|
||||
export function scrollUp(lines = 1): void {
|
||||
vimState.scrollPos = Math.max(vimState.scrollPos - lines, 0);
|
||||
}
|
||||
|
||||
export function scrollToTop(): void {
|
||||
vimState.scrollPos = 0;
|
||||
}
|
||||
|
||||
export function scrollToBottom(): void {
|
||||
vimState.scrollPos = maxScrollLines;
|
||||
}
|
||||
|
||||
export function pageUp(): void {
|
||||
scrollUp(15);
|
||||
}
|
||||
|
||||
export function pageDown(): void {
|
||||
scrollDown(15);
|
||||
}
|
||||
|
||||
export function startSearch(reverse = false): void {
|
||||
vimState.isSearching = true;
|
||||
vimState.searchReverse = reverse;
|
||||
vimState.searchQuery = "";
|
||||
vimState.mode = "search";
|
||||
}
|
||||
|
||||
export function performSearch(query: string, entries: UsageTrackingEntry[]): void {
|
||||
vimState.searchQuery = query;
|
||||
vimState.searchResults = [];
|
||||
vimState.currentSearchIdx = 0;
|
||||
|
||||
if (!query) {
|
||||
vimState.searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
entries.forEach((entry, idx) => {
|
||||
const searchable = [entry.modelId, entry.baseUrl || "", entry.client || ""].join(" ").toLowerCase();
|
||||
if (searchable.includes(lowerQuery)) {
|
||||
vimState.searchResults.push(idx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function nextSearchResult(totalEntries: number): void {
|
||||
if (vimState.searchResults.length === 0) return;
|
||||
vimState.currentSearchIdx = (vimState.currentSearchIdx + 1) % vimState.searchResults.length;
|
||||
vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines);
|
||||
}
|
||||
|
||||
export function prevSearchResult(totalEntries: number): void {
|
||||
if (vimState.searchResults.length === 0) return;
|
||||
vimState.currentSearchIdx = (vimState.currentSearchIdx - 1 + vimState.searchResults.length) % vimState.searchResults.length;
|
||||
vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines);
|
||||
}
|
||||
|
||||
export function exitSearchMode(): void {
|
||||
vimState.isSearching = false;
|
||||
vimState.mode = "normal";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export function clearScreen(): string {
|
||||
return "\x1b[2J\x1b[H";
|
||||
}
|
||||
|
||||
export function enterAlternateScreen(): string {
|
||||
return "\x1b[?1049h";
|
||||
}
|
||||
|
||||
export function leaveAlternateScreen(): string {
|
||||
return "\x1b[?1049l";
|
||||
}
|
||||
|
||||
export function hideCursor(): string {
|
||||
return "\x1b[?25l";
|
||||
}
|
||||
|
||||
export function showCursor(): string {
|
||||
return "\x1b[?25h";
|
||||
}
|
||||
|
||||
export function moveCursor(row: number, col: number): string {
|
||||
return `\x1b[${row};${col}H`;
|
||||
}
|
||||
|
||||
export function eraseDown(): string {
|
||||
return "\x1b[J";
|
||||
}
|
||||
|
||||
export function getWidth(): number {
|
||||
return process.stdout.columns || 80;
|
||||
}
|
||||
|
||||
export function getHeight(): number {
|
||||
return process.stdout.rows || 24;
|
||||
}
|
||||
|
||||
export function stripAnsi(str: string): string {
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
||||
|
||||
export interface UsageStats {
|
||||
entries: UsageTrackingEntry[];
|
||||
totalEntries: number;
|
||||
totalSatsCost: number;
|
||||
recentSatsCost: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface DayStats {
|
||||
date: string;
|
||||
requests: number;
|
||||
satsCost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ModelStats {
|
||||
modelId: string;
|
||||
requests: number;
|
||||
satsCost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
baseUrl: string;
|
||||
requests: number;
|
||||
satsCost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export interface ClientStats {
|
||||
client: string;
|
||||
requests: number;
|
||||
satsCost: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
export type TabId = "overview" | "today" | "models" | "providers" | "tokens" | "clients" | "recent";
|
||||
|
||||
export interface Tab {
|
||||
id: TabId;
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface VimState {
|
||||
scrollPos: number;
|
||||
searchQuery: string;
|
||||
searchResults: number[];
|
||||
currentSearchIdx: number;
|
||||
isSearching: boolean;
|
||||
searchReverse: boolean;
|
||||
mode: "normal" | "search";
|
||||
lastKey: string;
|
||||
lastKeyTime: number;
|
||||
}
|
||||
@@ -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
@@ -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}`));
|
||||
@@ -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);
|
||||
@@ -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"));
|
||||
Reference in New Issue
Block a user