routstrd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,247 @@
1
+ import { TABS } from "./constants.ts";
2
+ import { fetchBalance, fetchStatus, fetchUsage, type BalanceInfo, type StatusInfo } from "./data.ts";
3
+ import {
4
+ applyScrollToContent,
5
+ exitSearchMode,
6
+ nextSearchResult,
7
+ pageDown,
8
+ pageUp,
9
+ performSearch,
10
+ prevSearchResult,
11
+ scrollDown,
12
+ scrollToBottom,
13
+ scrollToTop,
14
+ scrollUp,
15
+ startSearch,
16
+ vimState,
17
+ } from "./state.ts";
18
+ import {
19
+ enterAlternateScreen,
20
+ eraseDown,
21
+ getHeight,
22
+ getWidth,
23
+ hideCursor,
24
+ leaveAlternateScreen,
25
+ moveCursor,
26
+ showCursor,
27
+ } from "./terminal.ts";
28
+ import { COLORS } from "./constants.ts";
29
+ import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
30
+ import type { TabId, UsageStats } from "./types.ts";
31
+ import { isDaemonRunning } from "../../cli-shared.ts";
32
+
33
+ export async function runUsageTui(): Promise<void> {
34
+ const running = await isDaemonRunning();
35
+ if (!running) {
36
+ console.log(`${COLORS.red}Error: routstrd daemon is not running.${COLORS.reset}`);
37
+ console.log(`Run ${COLORS.green}routstrd start${COLORS.reset} first.`);
38
+ process.exit(1);
39
+ }
40
+
41
+ const stdin = process.stdin;
42
+ const stdout = process.stdout;
43
+ const isInteractive = Boolean(stdout.isTTY && stdin.isTTY);
44
+
45
+ let currentTab: TabId = "overview";
46
+ let stats: UsageStats | null = null;
47
+ let balance: BalanceInfo | null = null;
48
+ let status: StatusInfo | null = null;
49
+ let refreshInterval: ReturnType<typeof setInterval> | null = null;
50
+ let shouldUpdate = true;
51
+ let autoRefresh = true;
52
+ let cleanedUp = false;
53
+ let rendering = false;
54
+
55
+ if (isInteractive) {
56
+ stdout.write(enterAlternateScreen() + hideCursor());
57
+ stdin.setRawMode?.(true);
58
+ stdin.resume();
59
+ stdin.setEncoding("utf-8");
60
+ }
61
+
62
+ function cleanup(exitCode = 0) {
63
+ if (cleanedUp) return;
64
+ cleanedUp = true;
65
+ if (refreshInterval) clearInterval(refreshInterval);
66
+
67
+ if (isInteractive) {
68
+ stdin.setRawMode?.(false);
69
+ stdin.pause();
70
+ stdout.write(showCursor() + leaveAlternateScreen());
71
+ } else {
72
+ stdout.write(showCursor());
73
+ }
74
+
75
+ process.exit(exitCode);
76
+ }
77
+
78
+ process.on("SIGINT", () => cleanup(0));
79
+ process.on("SIGTERM", () => cleanup(0));
80
+
81
+ async function render(forceFetch = false) {
82
+ if (rendering) return;
83
+ rendering = true;
84
+
85
+ try {
86
+ const width = getWidth();
87
+ const height = getHeight();
88
+
89
+ if (forceFetch || shouldUpdate) {
90
+ stats = await fetchUsage(1000);
91
+ balance = await fetchBalance();
92
+ status = await fetchStatus();
93
+ shouldUpdate = false;
94
+ }
95
+
96
+ if (!stats) {
97
+ stdout.write(
98
+ moveCursor(1, 1) +
99
+ eraseDown() +
100
+ `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
101
+ `Make sure routstrd is running.\n` +
102
+ `\nPress Q to quit.`
103
+ );
104
+ return;
105
+ }
106
+
107
+ const content = renderTabContent(currentTab, stats, balance, status, width);
108
+ 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}` : ""}`;
109
+ const chrome = renderHeader(currentTab, width) + renderTabs(currentTab) + renderSeparator(width) + renderSearchBar();
110
+ const chromeLines = chrome.split("\n").length - 1;
111
+ const footerSeparator = renderSeparator(width);
112
+ const footerLines = footerSeparator.split("\n").length - 1;
113
+ const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
114
+ const visibleContent = applyScrollToContent(content, contentViewportHeight);
115
+ const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
116
+
117
+ stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
118
+ } finally {
119
+ rendering = false;
120
+ }
121
+ }
122
+
123
+ const handleKey = (key: string) => {
124
+ if (vimState.isSearching) {
125
+ if (key === "\x1b" || key === "\x1b[3~") {
126
+ exitSearchMode();
127
+ void render(false);
128
+ return;
129
+ }
130
+ if (key === "\r" || key === "\n") {
131
+ if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
132
+ exitSearchMode();
133
+ void render(false);
134
+ return;
135
+ }
136
+ if (key === "\x7f" || key === "\x08") {
137
+ vimState.searchQuery = vimState.searchQuery.slice(0, -1);
138
+ if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
139
+ void render(false);
140
+ return;
141
+ }
142
+ if (key === "\x03") {
143
+ exitSearchMode();
144
+ void render(false);
145
+ return;
146
+ }
147
+ if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
148
+ vimState.searchQuery += key;
149
+ if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
150
+ void render(false);
151
+ }
152
+ return;
153
+ }
154
+
155
+ if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
156
+ if (key === "r" || key === "R") {
157
+ shouldUpdate = true;
158
+ void render(true);
159
+ return;
160
+ }
161
+ if (key === "a" || key === "A") {
162
+ autoRefresh = !autoRefresh;
163
+ shouldUpdate = true;
164
+ void render(false);
165
+ return;
166
+ }
167
+
168
+ if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
169
+ scrollDown();
170
+ void render(false);
171
+ return;
172
+ }
173
+ if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
174
+ scrollUp();
175
+ void render(false);
176
+ return;
177
+ }
178
+ if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
179
+ const currentIdx = TABS.findIndex((t) => t.id === currentTab);
180
+ currentTab = TABS[(currentIdx + 1) % TABS.length]!.id;
181
+ vimState.scrollPos = 0;
182
+ void render(false);
183
+ return;
184
+ }
185
+ if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
186
+ const currentIdx = TABS.findIndex((t) => t.id === currentTab);
187
+ currentTab = TABS[(currentIdx - 1 + TABS.length) % TABS.length]!.id;
188
+ vimState.scrollPos = 0;
189
+ void render(false);
190
+ return;
191
+ }
192
+
193
+ if (key === "g") {
194
+ if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
195
+ scrollToBottom();
196
+ vimState.lastKey = "";
197
+ void render(false);
198
+ return;
199
+ }
200
+ vimState.lastKey = "g";
201
+ vimState.lastKeyTime = Date.now();
202
+ scrollToTop();
203
+ void render(false);
204
+ return;
205
+ }
206
+
207
+ if (key === "\x02") { pageUp(); void render(false); return; }
208
+ if (key === "\x06") { pageDown(); void render(false); return; }
209
+ if (key === "\x15") { scrollUp(10); void render(false); return; }
210
+ if (key === "\x04") { scrollDown(10); void render(false); return; }
211
+ if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); void render(false); return; }
212
+ if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); void render(false); return; }
213
+ if (key === "/") { startSearch(false); void render(false); return; }
214
+ if (key === "?") { startSearch(true); void render(false); return; }
215
+ if (key === "n") {
216
+ if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
217
+ else nextSearchResult(stats?.entries.length || 0);
218
+ void render(false);
219
+ return;
220
+ }
221
+ if (key === "N") {
222
+ if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
223
+ else prevSearchResult(stats?.entries.length || 0);
224
+ void render(false);
225
+ return;
226
+ }
227
+ if (key === "\x1b") { scrollToTop(); void render(false); return; }
228
+
229
+ const tab = TABS.find((t) => t.key === key);
230
+ if (tab) {
231
+ currentTab = tab.id;
232
+ vimState.scrollPos = 0;
233
+ void render(false);
234
+ }
235
+ };
236
+
237
+ if (isInteractive) stdin.on("data", handleKey);
238
+
239
+ await render(true);
240
+
241
+ refreshInterval = setInterval(() => {
242
+ if (autoRefresh) {
243
+ shouldUpdate = true;
244
+ void render(true);
245
+ }
246
+ }, 2000);
247
+ }
@@ -0,0 +1,42 @@
1
+ import type { Tab } from "./types.ts";
2
+
3
+ export const TABS: Tab[] = [
4
+ { id: "overview", name: "Overview", key: "1" },
5
+ { id: "today", name: "Today", key: "2" },
6
+ { id: "models", name: "Models", key: "3" },
7
+ { id: "providers", name: "Providers", key: "4" },
8
+ { id: "tokens", name: "Tokens", key: "5" },
9
+ { id: "clients", name: "Clients", key: "6" },
10
+ { id: "recent", name: "Recent", key: "7" },
11
+ ];
12
+
13
+ export const COLORS = {
14
+ reset: "\x1b[0m",
15
+ bold: "\x1b[1m",
16
+ dim: "\x1b[2m",
17
+ red: "\x1b[31m",
18
+ green: "\x1b[32m",
19
+ yellow: "\x1b[33m",
20
+ blue: "\x1b[34m",
21
+ magenta: "\x1b[35m",
22
+ cyan: "\x1b[36m",
23
+ white: "\x1b[37m",
24
+ bgBlue: "\x1b[44m",
25
+ bgGreen: "\x1b[42m",
26
+ bgYellow: "\x1b[43m",
27
+ bright: "\x1b[1m",
28
+ };
29
+
30
+ export const MODEL_COLORS: Record<string, string> = {
31
+ "gpt-5.4": COLORS.magenta,
32
+ "minimax-m2.7": COLORS.cyan,
33
+ default: COLORS.white,
34
+ };
35
+
36
+ export const CLIENT_COLORS: Record<string, string> = {
37
+ opencode: COLORS.blue,
38
+ openclaw: COLORS.green,
39
+ "pi-agent": COLORS.yellow,
40
+ unknown: COLORS.dim,
41
+ default: COLORS.white,
42
+ };
@@ -0,0 +1,228 @@
1
+ import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
+ import { callDaemon, isDaemonRunning } from "../../cli-shared.ts";
3
+ import type { ClientStats, DayStats, ModelStats, ProviderStats, UsageStats } from "./types.ts";
4
+
5
+ export interface BalanceKey {
6
+ id: string;
7
+ name: string;
8
+ balance: number;
9
+ }
10
+
11
+ export interface BalanceInfo {
12
+ keys: BalanceKey[];
13
+ total: number;
14
+ unit: "sat";
15
+ apikeysCalled: number;
16
+ }
17
+
18
+ export interface StatusInfo {
19
+ daemon: string;
20
+ wallet: string;
21
+ mode: "xcashu" | "apikeys";
22
+ error?: string;
23
+ }
24
+
25
+ export async function fetchStatus(): Promise<StatusInfo | null> {
26
+ try {
27
+ const running = await isDaemonRunning();
28
+ if (!running) return null;
29
+
30
+ const result = await callDaemon("/status");
31
+ if (result.error) return null;
32
+
33
+ const output = result.output as {
34
+ daemon?: string;
35
+ wallet?: string;
36
+ mode?: "xcashu" | "apikeys";
37
+ error?: string;
38
+ };
39
+
40
+ return {
41
+ daemon: output?.daemon || "unknown",
42
+ wallet: output?.wallet || "unknown",
43
+ mode: output?.mode || "apikeys",
44
+ error: output?.error,
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function fetchBalance(): Promise<BalanceInfo | null> {
52
+ try {
53
+ const running = await isDaemonRunning();
54
+ if (!running) return null;
55
+
56
+ const result = await callDaemon("/keys/balance");
57
+ if (result.error) return null;
58
+
59
+ const output = result.output as {
60
+ keys?: BalanceKey[];
61
+ total?: number;
62
+ unit?: string;
63
+ apikeysCalled?: number;
64
+ };
65
+
66
+ return {
67
+ keys: output?.keys || [],
68
+ total: output?.total || 0,
69
+ unit: (output?.unit as "sat") || "sat",
70
+ apikeysCalled: output?.apikeysCalled || 0,
71
+ };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export async function fetchUsage(limit = 1000): Promise<UsageStats | null> {
78
+ try {
79
+ const running = await isDaemonRunning();
80
+ if (!running) return null;
81
+
82
+ const result = await callDaemon(`/usage?limit=${limit}`);
83
+ if (result.error) return null;
84
+
85
+ const output = result.output as {
86
+ entries?: UsageTrackingEntry[];
87
+ totalEntries?: number;
88
+ totalSatsCost?: number;
89
+ recentSatsCost?: number;
90
+ limit?: number;
91
+ };
92
+
93
+ return {
94
+ entries: output?.entries || [],
95
+ totalEntries: output?.totalEntries || 0,
96
+ totalSatsCost: output?.totalSatsCost || 0,
97
+ recentSatsCost: output?.recentSatsCost || 0,
98
+ limit: output?.limit || limit,
99
+ };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export function getTodayStart(): number {
106
+ const now = new Date();
107
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
108
+ }
109
+
110
+ export function formatDate(timestamp: number): string {
111
+ return new Date(timestamp).toISOString().split("T")[0] ?? "";
112
+ }
113
+
114
+ export function formatTime(timestamp: number): string {
115
+ return new Date(timestamp).toISOString().split("T")[1]?.slice(0, 8) ?? "";
116
+ }
117
+
118
+ export function formatNumber(n: number): string {
119
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
120
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
121
+ return n.toString();
122
+ }
123
+
124
+ export function getDayStats(entries: UsageTrackingEntry[]): Map<string, DayStats> {
125
+ const days = new Map<string, DayStats>();
126
+ for (const entry of entries) {
127
+ const date = formatDate(entry.timestamp);
128
+ const existing = days.get(date) || { date, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
129
+ days.set(date, {
130
+ ...existing,
131
+ requests: existing.requests + 1,
132
+ satsCost: existing.satsCost + entry.satsCost,
133
+ promptTokens: existing.promptTokens + entry.promptTokens,
134
+ completionTokens: existing.completionTokens + entry.completionTokens,
135
+ totalTokens: existing.totalTokens + entry.totalTokens,
136
+ });
137
+ }
138
+ return days;
139
+ }
140
+
141
+ export function getHourlyToday(entries: UsageTrackingEntry[]): Map<number, DayStats> {
142
+ const todayStart = getTodayStart();
143
+ const hours = new Map<number, DayStats>();
144
+ for (const entry of entries) {
145
+ if (entry.timestamp < todayStart) continue;
146
+ const hour = new Date(entry.timestamp).getHours();
147
+ const existing = hours.get(hour) || {
148
+ date: formatDate(entry.timestamp), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
149
+ };
150
+ hours.set(hour, {
151
+ ...existing,
152
+ requests: existing.requests + 1,
153
+ satsCost: existing.satsCost + entry.satsCost,
154
+ promptTokens: existing.promptTokens + entry.promptTokens,
155
+ completionTokens: existing.completionTokens + entry.completionTokens,
156
+ totalTokens: existing.totalTokens + entry.totalTokens,
157
+ });
158
+ }
159
+ return hours;
160
+ }
161
+
162
+ export function getModelStats(entries: UsageTrackingEntry[]): ModelStats[] {
163
+ const models = new Map<string, ModelStats>();
164
+ for (const entry of entries) {
165
+ const existing = models.get(entry.modelId) || {
166
+ modelId: entry.modelId, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
167
+ };
168
+ models.set(entry.modelId, {
169
+ ...existing,
170
+ requests: existing.requests + 1,
171
+ satsCost: existing.satsCost + entry.satsCost,
172
+ promptTokens: existing.promptTokens + entry.promptTokens,
173
+ completionTokens: existing.completionTokens + entry.completionTokens,
174
+ totalTokens: existing.totalTokens + entry.totalTokens,
175
+ });
176
+ }
177
+ return Array.from(models.values()).sort((a, b) => b.satsCost - a.satsCost);
178
+ }
179
+
180
+ export function getProviderStats(entries: UsageTrackingEntry[]): ProviderStats[] {
181
+ const providers = new Map<string, ProviderStats>();
182
+ for (const entry of entries) {
183
+ const url = entry.baseUrl || "unknown";
184
+ const existing = providers.get(url) || {
185
+ baseUrl: url, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
186
+ };
187
+ providers.set(url, {
188
+ ...existing,
189
+ requests: existing.requests + 1,
190
+ satsCost: existing.satsCost + entry.satsCost,
191
+ promptTokens: existing.promptTokens + entry.promptTokens,
192
+ completionTokens: existing.completionTokens + entry.completionTokens,
193
+ totalTokens: existing.totalTokens + entry.totalTokens,
194
+ });
195
+ }
196
+ return Array.from(providers.values()).sort((a, b) => b.satsCost - a.satsCost);
197
+ }
198
+
199
+ export function getClientStats(entries: UsageTrackingEntry[]): ClientStats[] {
200
+ const clients = new Map<string, ClientStats>();
201
+ for (const entry of entries) {
202
+ const client = entry.client || "unknown";
203
+ const existing = clients.get(client) || {
204
+ client, requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0,
205
+ };
206
+ clients.set(client, {
207
+ ...existing,
208
+ requests: existing.requests + 1,
209
+ satsCost: existing.satsCost + entry.satsCost,
210
+ promptTokens: existing.promptTokens + entry.promptTokens,
211
+ completionTokens: existing.completionTokens + entry.completionTokens,
212
+ totalTokens: existing.totalTokens + entry.totalTokens,
213
+ });
214
+ }
215
+ return Array.from(clients.values()).sort((a, b) => b.satsCost - a.satsCost);
216
+ }
217
+
218
+ export function getTotals(entries: UsageTrackingEntry[]) {
219
+ return entries.reduce(
220
+ (acc, entry) => ({
221
+ promptTokens: acc.promptTokens + entry.promptTokens,
222
+ completionTokens: acc.completionTokens + entry.completionTokens,
223
+ totalTokens: acc.totalTokens + entry.totalTokens,
224
+ satsCost: acc.satsCost + entry.satsCost,
225
+ }),
226
+ { promptTokens: 0, completionTokens: 0, totalTokens: 0, satsCost: 0 }
227
+ );
228
+ }
@@ -0,0 +1 @@
1
+ export { runUsageTui } from "./app.ts";