routstrd 0.3.3 → 0.3.5
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.
- package/bun.lock +44 -2
- package/dist/daemon/index.js +62630 -19364
- package/dist/index.js +86 -78
- package/package.json +2 -2
- package/src/daemon/http/usage-summary.test.ts +27 -14
- package/src/tui/usage/app.ts +84 -68
- package/src/tui/usage/data.ts +0 -12
- package/src/tui/usage/render.ts +13 -6
- package/say_hi.sh +0 -32
package/src/tui/usage/app.ts
CHANGED
|
@@ -49,10 +49,9 @@ export async function runUsageTui(): Promise<void> {
|
|
|
49
49
|
let clients: ClientInfo[] = [];
|
|
50
50
|
let visibleTabs: Tab[] = getVisibleTabs(false);
|
|
51
51
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
52
|
-
let shouldUpdate = true;
|
|
53
52
|
let autoRefresh = true;
|
|
54
53
|
let cleanedUp = false;
|
|
55
|
-
let
|
|
54
|
+
let fetching = false;
|
|
56
55
|
|
|
57
56
|
if (isInteractive) {
|
|
58
57
|
stdout.write(enterAlternateScreen() + hideCursor());
|
|
@@ -80,123 +79,141 @@ export async function runUsageTui(): Promise<void> {
|
|
|
80
79
|
process.on("SIGINT", () => cleanup(0));
|
|
81
80
|
process.on("SIGTERM", () => cleanup(0));
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Background fetch — async, never blocks rendering. Updates the state
|
|
84
|
+
* variables only on success, then triggers a repaint. Overlapping calls
|
|
85
|
+
* are skipped via the `fetching` guard so we don't race stale results.
|
|
86
|
+
*/
|
|
87
|
+
async function fetchData(): Promise<void> {
|
|
88
|
+
if (fetching) return;
|
|
89
|
+
fetching = true;
|
|
87
90
|
try {
|
|
88
|
-
const
|
|
89
|
-
|
|
91
|
+
const running = await isDaemonRunning();
|
|
92
|
+
if (!running) {
|
|
93
|
+
stats = null;
|
|
94
|
+
} else {
|
|
95
|
+
// Fire all 4 fetches concurrently — cuts the blocked window significantly.
|
|
96
|
+
const [newStats, newBalance, newStatus, newClients] = await Promise.all([
|
|
97
|
+
fetchUsageSummary(),
|
|
98
|
+
fetchBalance(),
|
|
99
|
+
fetchStatus(),
|
|
100
|
+
fetchClients(),
|
|
101
|
+
]);
|
|
102
|
+
if (newStats) stats = newStats;
|
|
103
|
+
if (newBalance) balance = newBalance;
|
|
104
|
+
if (newStatus) status = newStatus;
|
|
105
|
+
if (newClients && newClients.length > 0) clients = newClients;
|
|
90
106
|
|
|
91
|
-
if (forceFetch || shouldUpdate) {
|
|
92
|
-
stats = await fetchUsageSummary();
|
|
93
|
-
balance = await fetchBalance();
|
|
94
|
-
status = await fetchStatus();
|
|
95
|
-
clients = await fetchClients();
|
|
96
107
|
const npubsVisible = hasAnyNpubs(clients);
|
|
97
108
|
visibleTabs = getVisibleTabs(npubsVisible);
|
|
98
|
-
// If current tab is npubs but it's no longer visible, fall back to clients
|
|
99
109
|
if (currentTab === "npubs" && !npubsVisible) {
|
|
100
110
|
currentTab = "clients";
|
|
101
111
|
vimState.scrollPos = 0;
|
|
102
112
|
}
|
|
103
|
-
shouldUpdate = false;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!stats) {
|
|
107
|
-
stdout.write(
|
|
108
|
-
moveCursor(1, 1) +
|
|
109
|
-
eraseDown() +
|
|
110
|
-
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
|
|
111
|
-
`Make sure routstrd is running.\n` +
|
|
112
|
-
`\nPress Q to quit.`
|
|
113
|
-
);
|
|
114
|
-
return;
|
|
115
113
|
}
|
|
114
|
+
render();
|
|
115
|
+
} finally {
|
|
116
|
+
fetching = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const visibleContent = applyScrollToContent(content, contentViewportHeight);
|
|
125
|
-
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
|
|
120
|
+
/**
|
|
121
|
+
* Synchronous paint — never awaits I/O. Reads the current state variables
|
|
122
|
+
* and writes to stdout. Safe to call from key handlers without blocking.
|
|
123
|
+
*/
|
|
124
|
+
function render(): void {
|
|
125
|
+
const width = getWidth();
|
|
126
|
+
const height = getHeight();
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
if (!stats) {
|
|
129
|
+
stdout.write(
|
|
130
|
+
moveCursor(1, 1) +
|
|
131
|
+
eraseDown() +
|
|
132
|
+
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
|
|
133
|
+
`Make sure routstrd is running.\n` +
|
|
134
|
+
`\nPress Q to quit.`
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
130
137
|
}
|
|
138
|
+
|
|
139
|
+
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
|
|
140
|
+
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}` : ""}`;
|
|
141
|
+
const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
142
|
+
const chromeLines = chrome.split("\n").length - 1;
|
|
143
|
+
const footerSeparator = renderSeparator(width);
|
|
144
|
+
const footerLines = footerSeparator.split("\n").length - 1;
|
|
145
|
+
const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
|
|
146
|
+
const visibleContent = applyScrollToContent(content, contentViewportHeight);
|
|
147
|
+
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
|
|
148
|
+
|
|
149
|
+
stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
const handleKey = (key: string) => {
|
|
134
153
|
if (vimState.isSearching) {
|
|
135
154
|
if (key === "\x1b" || key === "\x1b[3~") {
|
|
136
155
|
exitSearchMode();
|
|
137
|
-
|
|
156
|
+
render();
|
|
138
157
|
return;
|
|
139
158
|
}
|
|
140
159
|
if (key === "\r" || key === "\n") {
|
|
141
160
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
142
161
|
exitSearchMode();
|
|
143
|
-
|
|
162
|
+
render();
|
|
144
163
|
return;
|
|
145
164
|
}
|
|
146
165
|
if (key === "\x7f" || key === "\x08") {
|
|
147
166
|
vimState.searchQuery = vimState.searchQuery.slice(0, -1);
|
|
148
167
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
149
|
-
|
|
168
|
+
render();
|
|
150
169
|
return;
|
|
151
170
|
}
|
|
152
171
|
if (key === "\x03") {
|
|
153
172
|
exitSearchMode();
|
|
154
|
-
|
|
173
|
+
render();
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
176
|
if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
|
|
158
177
|
vimState.searchQuery += key;
|
|
159
178
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
160
|
-
|
|
179
|
+
render();
|
|
161
180
|
}
|
|
162
181
|
return;
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
|
|
166
185
|
if (key === "r" || key === "R") {
|
|
167
|
-
|
|
168
|
-
void render(true);
|
|
186
|
+
void fetchData();
|
|
169
187
|
return;
|
|
170
188
|
}
|
|
171
189
|
if (key === "a" || key === "A") {
|
|
172
190
|
autoRefresh = !autoRefresh;
|
|
173
|
-
|
|
174
|
-
void render(false);
|
|
191
|
+
render();
|
|
175
192
|
return;
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
|
|
179
196
|
scrollDown();
|
|
180
|
-
|
|
197
|
+
render();
|
|
181
198
|
return;
|
|
182
199
|
}
|
|
183
200
|
if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
|
|
184
201
|
scrollUp();
|
|
185
|
-
|
|
202
|
+
render();
|
|
186
203
|
return;
|
|
187
204
|
}
|
|
188
205
|
if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
|
|
189
206
|
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
|
|
190
207
|
currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
|
|
191
208
|
vimState.scrollPos = 0;
|
|
192
|
-
|
|
209
|
+
render();
|
|
193
210
|
return;
|
|
194
211
|
}
|
|
195
212
|
if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
|
|
196
213
|
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
|
|
197
214
|
currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
|
|
198
215
|
vimState.scrollPos = 0;
|
|
199
|
-
|
|
216
|
+
render();
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -204,54 +221,53 @@ export async function runUsageTui(): Promise<void> {
|
|
|
204
221
|
if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
|
|
205
222
|
scrollToBottom();
|
|
206
223
|
vimState.lastKey = "";
|
|
207
|
-
|
|
224
|
+
render();
|
|
208
225
|
return;
|
|
209
226
|
}
|
|
210
227
|
vimState.lastKey = "g";
|
|
211
228
|
vimState.lastKeyTime = Date.now();
|
|
212
229
|
scrollToTop();
|
|
213
|
-
|
|
230
|
+
render();
|
|
214
231
|
return;
|
|
215
232
|
}
|
|
216
233
|
|
|
217
|
-
if (key === "\x02") { pageUp();
|
|
218
|
-
if (key === "\x06") { pageDown();
|
|
219
|
-
if (key === "\x15") { scrollUp(10);
|
|
220
|
-
if (key === "\x04") { scrollDown(10);
|
|
221
|
-
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop();
|
|
222
|
-
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom();
|
|
223
|
-
if (key === "/") { startSearch(false);
|
|
224
|
-
if (key === "?") { startSearch(true);
|
|
234
|
+
if (key === "\x02") { pageUp(); render(); return; }
|
|
235
|
+
if (key === "\x06") { pageDown(); render(); return; }
|
|
236
|
+
if (key === "\x15") { scrollUp(10); render(); return; }
|
|
237
|
+
if (key === "\x04") { scrollDown(10); render(); return; }
|
|
238
|
+
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
|
|
239
|
+
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
|
|
240
|
+
if (key === "/") { startSearch(false); render(); return; }
|
|
241
|
+
if (key === "?") { startSearch(true); render(); return; }
|
|
225
242
|
if (key === "n") {
|
|
226
243
|
if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
|
|
227
244
|
else nextSearchResult(stats?.entries.length || 0);
|
|
228
|
-
|
|
245
|
+
render();
|
|
229
246
|
return;
|
|
230
247
|
}
|
|
231
248
|
if (key === "N") {
|
|
232
249
|
if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
|
|
233
250
|
else prevSearchResult(stats?.entries.length || 0);
|
|
234
|
-
|
|
251
|
+
render();
|
|
235
252
|
return;
|
|
236
253
|
}
|
|
237
|
-
if (key === "\x1b") { scrollToTop();
|
|
254
|
+
if (key === "\x1b") { scrollToTop(); render(); return; }
|
|
238
255
|
|
|
239
256
|
const tab = visibleTabs.find((t) => t.key === key);
|
|
240
257
|
if (tab) {
|
|
241
258
|
currentTab = tab.id;
|
|
242
259
|
vimState.scrollPos = 0;
|
|
243
|
-
|
|
260
|
+
render();
|
|
244
261
|
}
|
|
245
262
|
};
|
|
246
263
|
|
|
247
264
|
if (isInteractive) stdin.on("data", handleKey);
|
|
248
265
|
|
|
249
|
-
await
|
|
266
|
+
await fetchData();
|
|
250
267
|
|
|
251
268
|
refreshInterval = setInterval(() => {
|
|
252
269
|
if (autoRefresh) {
|
|
253
|
-
|
|
254
|
-
void render(true);
|
|
270
|
+
void fetchData();
|
|
255
271
|
}
|
|
256
272
|
}, 2000);
|
|
257
273
|
}
|
package/src/tui/usage/data.ts
CHANGED
|
@@ -26,9 +26,6 @@ export interface StatusInfo {
|
|
|
26
26
|
|
|
27
27
|
export async function fetchStatus(): Promise<StatusInfo | null> {
|
|
28
28
|
try {
|
|
29
|
-
const running = await isDaemonRunning();
|
|
30
|
-
if (!running) return null;
|
|
31
|
-
|
|
32
29
|
const result = await callDaemon("/status");
|
|
33
30
|
if (result.error) return null;
|
|
34
31
|
|
|
@@ -52,9 +49,6 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
|
|
|
52
49
|
|
|
53
50
|
export async function fetchBalance(): Promise<BalanceInfo | null> {
|
|
54
51
|
try {
|
|
55
|
-
const running = await isDaemonRunning();
|
|
56
|
-
if (!running) return null;
|
|
57
|
-
|
|
58
52
|
const result = await callDaemon("/keys/balance");
|
|
59
53
|
if (result.error) return null;
|
|
60
54
|
|
|
@@ -78,9 +72,6 @@ export async function fetchBalance(): Promise<BalanceInfo | null> {
|
|
|
78
72
|
|
|
79
73
|
export async function fetchUsageSummary(): Promise<UsageStats | null> {
|
|
80
74
|
try {
|
|
81
|
-
const running = await isDaemonRunning();
|
|
82
|
-
if (!running) return null;
|
|
83
|
-
|
|
84
75
|
const tz = new Date().getTimezoneOffset();
|
|
85
76
|
const result = await callDaemon(`/usage/summary?tz=${tz}`);
|
|
86
77
|
if (result.error) return null;
|
|
@@ -123,9 +114,6 @@ export interface ClientInfo {
|
|
|
123
114
|
|
|
124
115
|
export async function fetchClients(): Promise<ClientInfo[]> {
|
|
125
116
|
try {
|
|
126
|
-
const running = await isDaemonRunning();
|
|
127
|
-
if (!running) return [];
|
|
128
|
-
|
|
129
117
|
const result = await callDaemon("/clients");
|
|
130
118
|
if (result.error) return [];
|
|
131
119
|
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -278,18 +278,25 @@ export function renderToday(stats: UsageStats, width: number): string {
|
|
|
278
278
|
const _now = new Date();
|
|
279
279
|
const todayDateStr = `${_now.getFullYear()}-${String(_now.getMonth() + 1).padStart(2, "0")}-${String(_now.getDate()).padStart(2, "0")}`;
|
|
280
280
|
|
|
281
|
-
let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number }
|
|
282
|
-
|
|
283
|
-
|
|
281
|
+
let todayStats: { date: string; requests: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number } = {
|
|
282
|
+
date: todayDateStr,
|
|
283
|
+
requests: 0,
|
|
284
|
+
satsCost: 0,
|
|
285
|
+
promptTokens: 0,
|
|
286
|
+
completionTokens: 0,
|
|
287
|
+
totalTokens: 0,
|
|
288
|
+
};
|
|
289
|
+
let recentDays: Array<{ date: string; requests: number; satsCost: number; totalTokens: number; promptTokens: number; completionTokens: number }> = [];
|
|
290
|
+
let hourlyMap: Map<number, { requests: number; satsCost: number }> = new Map();
|
|
284
291
|
|
|
285
292
|
if (stats.summary) {
|
|
286
293
|
// Use server-aggregated data
|
|
287
294
|
const { days, hoursToday } = stats.summary;
|
|
288
295
|
// days[0] is most-recent-first; check if it's today
|
|
289
296
|
const todayDayStat = days[0]?.date === todayDateStr ? days[0] : undefined;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
297
|
+
if (todayDayStat) {
|
|
298
|
+
todayStats = { date: todayDayStat.date, requests: todayDayStat.requests, satsCost: todayDayStat.satsCost, promptTokens: todayDayStat.promptTokens, completionTokens: todayDayStat.completionTokens, totalTokens: todayDayStat.totalTokens };
|
|
299
|
+
}
|
|
293
300
|
// Exclude today by date (it has its own box) rather than by position —
|
|
294
301
|
// days[0] is only today when there was activity today.
|
|
295
302
|
recentDays = days.filter((d) => d.date !== todayDateStr).slice(0, 6);
|
package/say_hi.sh
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
set -euo pipefail
|
|
3
|
-
|
|
4
|
-
LANGUAGES=(
|
|
5
|
-
"Mandarin Chinese"
|
|
6
|
-
"Spanish"
|
|
7
|
-
"English"
|
|
8
|
-
"Hindi"
|
|
9
|
-
"Arabic"
|
|
10
|
-
"Portuguese"
|
|
11
|
-
"Bengali"
|
|
12
|
-
"Russian"
|
|
13
|
-
"Japanese"
|
|
14
|
-
"German"
|
|
15
|
-
"French"
|
|
16
|
-
"Italian"
|
|
17
|
-
"Korean"
|
|
18
|
-
"Turkish"
|
|
19
|
-
"Vietnamese"
|
|
20
|
-
"Tamil"
|
|
21
|
-
"Polish"
|
|
22
|
-
"Dutch"
|
|
23
|
-
"Swahili"
|
|
24
|
-
"Thai"
|
|
25
|
-
"Greek"
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
for lang in "${LANGUAGES[@]}"; do
|
|
29
|
-
echo "=== $lang ==="
|
|
30
|
-
pi -p -c "in $lang"
|
|
31
|
-
echo
|
|
32
|
-
done
|