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.
- package/.claude/settings.local.json +7 -0
- package/README.md +191 -0
- package/bun.lock +376 -0
- package/dist/index.js +27019 -0
- package/package.json +34 -0
- package/routstr-cost-logging.md +71 -0
- package/src/TUI refactor.md +113 -0
- package/src/cli-shared.ts +204 -0
- package/src/cli.ts +650 -0
- package/src/daemon/args.ts +19 -0
- package/src/daemon/config-store.ts +36 -0
- package/src/daemon/http/index.ts +608 -0
- package/src/daemon/index.ts +151 -0
- package/src/daemon/models.ts +49 -0
- package/src/daemon/sse.ts +98 -0
- package/src/daemon/types.ts +25 -0
- package/src/daemon/wallet/index.ts +207 -0
- package/src/daemon.ts +1 -0
- package/src/index.ts +4 -0
- package/src/integrations/index.ts +67 -0
- package/src/integrations/openclaw.ts +177 -0
- package/src/integrations/opencode.ts +120 -0
- package/src/integrations/pi.ts +116 -0
- package/src/start-daemon.ts +90 -0
- package/src/tui/usage/app.ts +247 -0
- package/src/tui/usage/constants.ts +42 -0
- package/src/tui/usage/data.ts +228 -0
- package/src/tui/usage/index.ts +1 -0
- package/src/tui/usage/render.ts +539 -0
- package/src/tui/usage/state.ts +100 -0
- package/src/tui/usage/terminal.ts +39 -0
- package/src/tui/usage/types.ts +65 -0
- package/src/utils/config.ts +22 -0
- package/src/utils/logger.ts +54 -0
- package/test_box.ts +15 -0
- package/test_curl.sh +11 -0
- package/test_split_box.ts +17 -0
- package/test_split_box2.ts +23 -0
- package/tsconfig.json +20 -0
- package/v1-messages-format-report.md +223 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { CLIENT_COLORS, COLORS, MODEL_COLORS, TABS } from "./constants.ts";
|
|
2
|
+
import {
|
|
3
|
+
formatDate,
|
|
4
|
+
formatNumber,
|
|
5
|
+
formatTime,
|
|
6
|
+
getClientStats,
|
|
7
|
+
getDayStats,
|
|
8
|
+
getHourlyToday,
|
|
9
|
+
getModelStats,
|
|
10
|
+
getProviderStats,
|
|
11
|
+
getTodayStart,
|
|
12
|
+
getTotals,
|
|
13
|
+
} from "./data.ts";
|
|
14
|
+
import { vimState } from "./state.ts";
|
|
15
|
+
import { stripAnsi } from "./terminal.ts";
|
|
16
|
+
import type { BalanceInfo, StatusInfo } from "./data.ts";
|
|
17
|
+
import type { TabId, UsageStats } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
|
|
20
|
+
function formatCost(value: number): string {
|
|
21
|
+
if (value >= 1_000_000) return (value / 1_000_000).toFixed(2) + "m";
|
|
22
|
+
if (value >= 1_000) return (value / 1_000).toFixed(2) + "k";
|
|
23
|
+
return value.toFixed(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Format request count: 1, 12, 123, 1.2k, 1.2m */
|
|
27
|
+
function formatReqs(value: number): string {
|
|
28
|
+
if (value >= 1_000_000) return (value / 1_000_000).toFixed(1) + "m";
|
|
29
|
+
if (value >= 1_000) return (value / 1_000).toFixed(1) + "k";
|
|
30
|
+
return value.toString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderHeader(activeTab: TabId, width: number): string {
|
|
34
|
+
const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
|
|
35
|
+
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
|
|
36
|
+
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-7] Tabs [R] Refresh${COLORS.reset}`;
|
|
37
|
+
const fill = width - title.length - help.length - vimIndicator.length - 6;
|
|
38
|
+
return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderSearchBar(): string {
|
|
42
|
+
if (!vimState.isSearching) return "";
|
|
43
|
+
const prompt = vimState.searchReverse ? "?" : "/";
|
|
44
|
+
const matches = vimState.searchResults.length > 0
|
|
45
|
+
? ` (${vimState.currentSearchIdx + 1}/${vimState.searchResults.length})`
|
|
46
|
+
: "";
|
|
47
|
+
const searchLine = `${COLORS.yellow}${prompt}${COLORS.reset}${vimState.searchQuery}${COLORS.dim}_${COLORS.reset}${matches} `;
|
|
48
|
+
const placeholder = `${COLORS.dim}type to search, Enter to confirm, Esc to cancel${COLORS.reset}`;
|
|
49
|
+
return `\n${searchLine}${placeholder}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderTabs(activeTab: TabId): string {
|
|
53
|
+
const tabStr = TABS.map((tab) => tab.id === activeTab
|
|
54
|
+
? `${COLORS.bgBlue} ${tab.key}:${tab.name} ${COLORS.reset}`
|
|
55
|
+
: `${COLORS.dim}[${tab.key}]${COLORS.reset} ${tab.name}`).join(" ");
|
|
56
|
+
return `${" ".repeat(2)}${tabStr}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderSeparator(width: number): string {
|
|
60
|
+
return `${COLORS.dim}${"─".repeat(width)}${COLORS.reset}\n`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderBox(lines: string[], width: number, title?: string): string {
|
|
64
|
+
const result: string[] = [];
|
|
65
|
+
const innerWidth = Math.max(0, width - 4);
|
|
66
|
+
|
|
67
|
+
if (title) {
|
|
68
|
+
const titleStr = ` ${title} `;
|
|
69
|
+
const dashCount = Math.max(0, width - 2 - titleStr.length - 1);
|
|
70
|
+
result.push(`┌─${titleStr}${"─".repeat(dashCount)}┐`);
|
|
71
|
+
} else {
|
|
72
|
+
result.push(`┌${"─".repeat(Math.max(0, width - 2))}┐`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const padding = Math.max(0, innerWidth - stripAnsi(line).length);
|
|
77
|
+
result.push(`│ ${line}${" ".repeat(padding)} │`);
|
|
78
|
+
}
|
|
79
|
+
result.push(`└${"─".repeat(Math.max(0, width - 2))}┘`);
|
|
80
|
+
return result.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const _sectionMaxLabelLen = new Map<string, number>();
|
|
84
|
+
|
|
85
|
+
export function startBarSection(sectionKey: string, maxLabelLen: number): void {
|
|
86
|
+
_sectionMaxLabelLen.set(sectionKey, maxLabelLen);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function endBarSection(_sectionKey: string): void {
|
|
90
|
+
// kept for API compat; no-op since we compute max per call now
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function renderBarChart(
|
|
94
|
+
label: string,
|
|
95
|
+
value: number,
|
|
96
|
+
maxValue: number,
|
|
97
|
+
width: number,
|
|
98
|
+
color: string,
|
|
99
|
+
percentageValue?: number,
|
|
100
|
+
sectionKey?: string,
|
|
101
|
+
): string {
|
|
102
|
+
const safeMaxValue = Math.max(maxValue, 1);
|
|
103
|
+
const pct = percentageValue !== undefined
|
|
104
|
+
? percentageValue.toFixed(1)
|
|
105
|
+
: ((value / safeMaxValue) * 100).toFixed(1);
|
|
106
|
+
const suffix = ` ${pct}%`;
|
|
107
|
+
|
|
108
|
+
const maxLen = sectionKey ? (_sectionMaxLabelLen.get(sectionKey) ?? label.length) : label.length;
|
|
109
|
+
const paddedLabel = label.padEnd(maxLen);
|
|
110
|
+
const reserved = suffix.length + 1;
|
|
111
|
+
const maxBarWidth = Math.max(0, width - paddedLabel.length - reserved);
|
|
112
|
+
const barLen = Math.max(0, Math.round((value / safeMaxValue) * maxBarWidth));
|
|
113
|
+
const bar = color + "█".repeat(barLen) + COLORS.reset;
|
|
114
|
+
return `${paddedLabel} ${bar}${suffix}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
|
|
119
|
+
const totals = getTotals(stats.entries);
|
|
120
|
+
const entryCount = stats.entries.length;
|
|
121
|
+
const totalVisibleCost = totals.satsCost;
|
|
122
|
+
const avgCost = entryCount > 0 ? totalVisibleCost / entryCount : 0;
|
|
123
|
+
const avgTokens = entryCount > 0 ? totals.totalTokens / entryCount : 0;
|
|
124
|
+
|
|
125
|
+
const leftBox = [
|
|
126
|
+
`${COLORS.bold}Total Spent:${COLORS.reset} ${COLORS.green}${formatCost(totalVisibleCost)} sats${COLORS.reset}`,
|
|
127
|
+
`${COLORS.bold}Total Requests:${COLORS.reset} ${formatReqs(entryCount)}`,
|
|
128
|
+
`${COLORS.bold}Avg Cost/Req:${COLORS.reset} ${formatCost(avgCost)} sats`,
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
const rightBox = [
|
|
132
|
+
`${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber(totals.totalTokens)}`,
|
|
133
|
+
`${COLORS.bold}Avg Tokens/Req:${COLORS.reset} ${formatNumber(Math.round(avgTokens))}`,
|
|
134
|
+
`${COLORS.bold}Prompt/Comp:${COLORS.reset} ${(totals.promptTokens / Math.max(1, totals.completionTokens)).toFixed(2)}x`,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const halfWidth1 = Math.floor(width / 2);
|
|
138
|
+
const halfWidth2 = width - halfWidth1;
|
|
139
|
+
|
|
140
|
+
const leftBoxStr = renderBox(leftBox, halfWidth1, "Stats of Sats");
|
|
141
|
+
const rightBoxStr = renderBox(rightBox, halfWidth2, "Token Stats");
|
|
142
|
+
|
|
143
|
+
const leftLines = leftBoxStr.split("\n");
|
|
144
|
+
const rightLines = rightBoxStr.split("\n");
|
|
145
|
+
const maxLines = Math.max(leftLines.length, rightLines.length);
|
|
146
|
+
|
|
147
|
+
const combinedLines: string[] = [];
|
|
148
|
+
for (let i = 0; i < maxLines; i++) {
|
|
149
|
+
const l = leftLines[i] || " ".repeat(halfWidth1);
|
|
150
|
+
const r = rightLines[i] || " ".repeat(halfWidth2);
|
|
151
|
+
combinedLines.push(l + r);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let output = combinedLines.join("\n");
|
|
155
|
+
|
|
156
|
+
// Status and Balance boxes side by side
|
|
157
|
+
const statusLines: string[] = [];
|
|
158
|
+
if (status) {
|
|
159
|
+
const daemonColor = status.daemon === "running" ? COLORS.green : COLORS.red;
|
|
160
|
+
const walletColor = status.wallet === "connected" ? COLORS.green : COLORS.red;
|
|
161
|
+
const modeColor = COLORS.cyan;
|
|
162
|
+
statusLines.push(`${COLORS.bold}Daemon:${COLORS.reset} ${daemonColor}${status.daemon}${COLORS.reset}`);
|
|
163
|
+
statusLines.push(`${COLORS.bold}Wallet:${COLORS.reset} ${walletColor}${status.wallet}${COLORS.reset}`);
|
|
164
|
+
statusLines.push(`${COLORS.bold}Mode:${COLORS.reset} ${modeColor}${status.mode}${COLORS.reset}`);
|
|
165
|
+
if (status.error) {
|
|
166
|
+
statusLines.push(`${COLORS.bold}Error:${COLORS.reset} ${COLORS.red}${status.error}${COLORS.reset}`);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
statusLines.push(`${COLORS.dim}Status unavailable${COLORS.reset}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Display all balances (wallet, cached tokens, API keys) if available
|
|
173
|
+
if (balance && balance.keys.length > 0) {
|
|
174
|
+
const balanceLines: string[] = [];
|
|
175
|
+
const totalBalance = balance.total;
|
|
176
|
+
|
|
177
|
+
if (totalBalance > 0) {
|
|
178
|
+
balanceLines.push(`${COLORS.bold}Total Balance:${COLORS.reset} ${COLORS.green}${totalBalance.toLocaleString()} sat${COLORS.reset}`);
|
|
179
|
+
} else {
|
|
180
|
+
balanceLines.push(`${COLORS.bold}Total Balance:${COLORS.reset} ${COLORS.red}0 sat${COLORS.reset}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const key of balance.keys) {
|
|
184
|
+
const color = key.id === "wallet" ? COLORS.green : COLORS.cyan;
|
|
185
|
+
if (key.id === "wallet") {
|
|
186
|
+
balanceLines.push(`${color}Wallet:${COLORS.reset} ${key.balance.toLocaleString()} sat`);
|
|
187
|
+
} else {
|
|
188
|
+
// Extract provider URL from name (e.g., "API Key: https://..." or "Cached: https://...")
|
|
189
|
+
const providerUrl = key.name.replace(/^(API Key|Cached):\s*/, "");
|
|
190
|
+
const shortProvider = providerUrl.replace("https://", "").replace("http://", "");
|
|
191
|
+
const label = key.id.startsWith("cached:") ? "Cached" : "API Key";
|
|
192
|
+
balanceLines.push(`${color}${label}:${COLORS.reset} ${shortProvider} (${key.balance.toLocaleString()} sat)`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (totalBalance === 0) {
|
|
197
|
+
balanceLines.push(`${COLORS.dim}No funds available${COLORS.reset}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Render status and balance boxes side by side
|
|
201
|
+
const balanceBoxStr = renderBox(balanceLines, halfWidth1, "Balance");
|
|
202
|
+
const statusBoxStr = renderBox(statusLines, halfWidth2, "System Status");
|
|
203
|
+
const balanceBoxLines = balanceBoxStr.split("\n");
|
|
204
|
+
const statusBoxLines = statusBoxStr.split("\n");
|
|
205
|
+
const statusBalanceMaxLines = Math.max(balanceBoxLines.length, statusBoxLines.length);
|
|
206
|
+
|
|
207
|
+
const statusBalanceLines: string[] = [];
|
|
208
|
+
for (let i = 0; i < statusBalanceMaxLines; i++) {
|
|
209
|
+
const b = balanceBoxLines[i] || " ".repeat(halfWidth1);
|
|
210
|
+
const s = statusBoxLines[i] || " ".repeat(halfWidth2);
|
|
211
|
+
statusBalanceLines.push(b + s);
|
|
212
|
+
}
|
|
213
|
+
output = statusBalanceLines.join("\n") + "\n" + output;
|
|
214
|
+
} else if (balance && balance.keys.length === 0) {
|
|
215
|
+
// Only balance is empty, show status next to empty balance box
|
|
216
|
+
const balanceLines: string[] = [
|
|
217
|
+
`${COLORS.bold}Total Balance:${COLORS.reset} ${COLORS.red}0 sat${COLORS.reset}`,
|
|
218
|
+
`${COLORS.dim}No funds available${COLORS.reset}`,
|
|
219
|
+
];
|
|
220
|
+
const balanceBoxStr = renderBox(balanceLines, halfWidth1, "Balance");
|
|
221
|
+
const statusBoxStr = renderBox(statusLines, halfWidth2, "System Status");
|
|
222
|
+
const balanceBoxLines = balanceBoxStr.split("\n");
|
|
223
|
+
const statusBoxLines = statusBoxStr.split("\n");
|
|
224
|
+
const statusBalanceMaxLines = Math.max(balanceBoxLines.length, statusBoxLines.length);
|
|
225
|
+
|
|
226
|
+
const statusBalanceLines: string[] = [];
|
|
227
|
+
for (let i = 0; i < statusBalanceMaxLines; i++) {
|
|
228
|
+
const b = balanceBoxLines[i] || " ".repeat(halfWidth1);
|
|
229
|
+
const s = statusBoxLines[i] || " ".repeat(halfWidth2);
|
|
230
|
+
statusBalanceLines.push(b + s);
|
|
231
|
+
}
|
|
232
|
+
output = statusBalanceLines.join("\n") + "\n" + output;
|
|
233
|
+
} else {
|
|
234
|
+
// No balance data, just show status box full width
|
|
235
|
+
output = renderBox(statusLines, width, "System Status") + "\n" + output;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const modelStats = getModelStats(stats.entries);
|
|
239
|
+
if (modelStats.length > 0) {
|
|
240
|
+
const maxCost = modelStats[0]!.satsCost;
|
|
241
|
+
const totalCost = Math.max(totalVisibleCost, 1);
|
|
242
|
+
const maxModelLabel = Math.max(...modelStats.slice(0, 5).map((m) => m.modelId.length)) + 1;
|
|
243
|
+
startBarSection("models", maxModelLabel);
|
|
244
|
+
const modelLines = modelStats.slice(0, 5).map((m) => renderBarChart(
|
|
245
|
+
m.modelId + " ",
|
|
246
|
+
m.satsCost,
|
|
247
|
+
maxCost,
|
|
248
|
+
width - 4,
|
|
249
|
+
MODEL_COLORS[m.modelId] || MODEL_COLORS.default || COLORS.white,
|
|
250
|
+
(m.satsCost / totalCost) * 100,
|
|
251
|
+
"models",
|
|
252
|
+
));
|
|
253
|
+
endBarSection("models");
|
|
254
|
+
output += "\n" + renderBox(modelLines, width, "Top Models by Cost");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const clientStats = getClientStats(stats.entries);
|
|
258
|
+
if (clientStats.length > 0) {
|
|
259
|
+
const maxCost = clientStats[0]!.satsCost;
|
|
260
|
+
const totalCost = Math.max(totalVisibleCost, 1);
|
|
261
|
+
const maxClientLabel = Math.max(...clientStats.slice(0, 5).map((c) => c.client.length)) + 1;
|
|
262
|
+
startBarSection("clients", maxClientLabel);
|
|
263
|
+
const clientLines = clientStats.slice(0, 5).map((c) => renderBarChart(
|
|
264
|
+
c.client + " ",
|
|
265
|
+
c.satsCost,
|
|
266
|
+
maxCost,
|
|
267
|
+
width - 4,
|
|
268
|
+
CLIENT_COLORS[c.client] || CLIENT_COLORS.default || COLORS.white,
|
|
269
|
+
(c.satsCost / totalCost) * 100,
|
|
270
|
+
"clients",
|
|
271
|
+
));
|
|
272
|
+
endBarSection("clients");
|
|
273
|
+
output += "\n" + renderBox(clientLines, width, "Usage by Client");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return output;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function renderToday(stats: UsageStats, width: number): string {
|
|
280
|
+
const hourly = getHourlyToday(stats.entries);
|
|
281
|
+
const todayStart = getTodayStart();
|
|
282
|
+
const currentHour = new Date().getHours();
|
|
283
|
+
const todayStats = { date: formatDate(Date.now()), requests: 0, satsCost: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
284
|
+
|
|
285
|
+
for (const entry of stats.entries) {
|
|
286
|
+
if (entry.timestamp >= todayStart) {
|
|
287
|
+
todayStats.requests++;
|
|
288
|
+
todayStats.satsCost += entry.satsCost;
|
|
289
|
+
todayStats.promptTokens += entry.promptTokens;
|
|
290
|
+
todayStats.completionTokens += entry.completionTokens;
|
|
291
|
+
todayStats.totalTokens += entry.totalTokens;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const summaryLines = [
|
|
296
|
+
`${COLORS.bold}Date:${COLORS.reset} ${todayStats.date}`,
|
|
297
|
+
`${COLORS.bold}Requests:${COLORS.reset} ${formatReqs(todayStats.requests)}`,
|
|
298
|
+
`${COLORS.bold}Cost:${COLORS.reset} ${COLORS.green}${formatCost(todayStats.satsCost)} sats${COLORS.reset}`,
|
|
299
|
+
`${COLORS.bold}Tokens:${COLORS.reset} ${formatNumber(todayStats.totalTokens)} (p: ${formatNumber(todayStats.promptTokens)} + c: ${formatNumber(todayStats.completionTokens)})`,
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
let output = renderBox(summaryLines, width, "Today");
|
|
303
|
+
|
|
304
|
+
const days = Array.from(getDayStats(stats.entries).values()).slice(0, 7);
|
|
305
|
+
if (days.length > 1) {
|
|
306
|
+
const dayLines = days.slice(1).map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`);
|
|
307
|
+
output += "\n" + renderBox(dayLines, width, "Recent Days");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const hourLines: string[] = [];
|
|
311
|
+
const maxHourCost = Math.max(...Array.from(hourly.values()).map((h) => h.satsCost), 1);
|
|
312
|
+
const totalTodayCost = Math.max(todayStats.satsCost, 1);
|
|
313
|
+
const hourLabels: string[] = [];
|
|
314
|
+
for (let h = currentHour; h >= 0; h--) {
|
|
315
|
+
const hStat = hourly.get(h);
|
|
316
|
+
const reqs = hStat?.requests || 0;
|
|
317
|
+
const cost = hStat?.satsCost || 0;
|
|
318
|
+
hourLabels.push(`${h.toString().padStart(2, "0")}:00 (${formatReqs(reqs)} req, ${formatCost(cost)} sats) `);
|
|
319
|
+
}
|
|
320
|
+
const maxHourLabel = Math.max(...hourLabels.map((l) => l.length));
|
|
321
|
+
startBarSection("hourly", maxHourLabel);
|
|
322
|
+
for (let i = currentHour; i >= 0; i--) {
|
|
323
|
+
const hStat = hourly.get(i);
|
|
324
|
+
const reqs = hStat?.requests || 0;
|
|
325
|
+
const cost = hStat?.satsCost || 0;
|
|
326
|
+
hourLines.push(renderBarChart(
|
|
327
|
+
hourLabels[currentHour - i]!,
|
|
328
|
+
cost,
|
|
329
|
+
maxHourCost,
|
|
330
|
+
width - 4,
|
|
331
|
+
i === currentHour ? COLORS.green : COLORS.cyan,
|
|
332
|
+
(cost / totalTodayCost) * 100,
|
|
333
|
+
"hourly",
|
|
334
|
+
));
|
|
335
|
+
}
|
|
336
|
+
endBarSection("hourly");
|
|
337
|
+
|
|
338
|
+
output += "\n" + renderBox(hourLines.length > 0 ? hourLines : ["No activity today yet"], width, "Hourly Activity");
|
|
339
|
+
|
|
340
|
+
return output;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function renderModels(stats: UsageStats, width: number): string {
|
|
344
|
+
const modelStats = getModelStats(stats.entries);
|
|
345
|
+
if (modelStats.length === 0) return renderBox(["No model data available"], width, "Models");
|
|
346
|
+
|
|
347
|
+
const totalCost = getTotals(stats.entries).satsCost;
|
|
348
|
+
const maxCost = modelStats[0]!.satsCost;
|
|
349
|
+
const maxModelLabel = Math.max(...modelStats.map((m) => m.modelId.length));
|
|
350
|
+
const lines: string[] = [];
|
|
351
|
+
|
|
352
|
+
startBarSection("model-detail", maxModelLabel);
|
|
353
|
+
for (const model of modelStats) {
|
|
354
|
+
const color = MODEL_COLORS[model.modelId] || MODEL_COLORS.default || COLORS.white;
|
|
355
|
+
const pct = totalCost > 0 ? ((model.satsCost / totalCost) * 100).toFixed(1) : "0.0";
|
|
356
|
+
lines.push(`${color}${COLORS.bold}${model.modelId}${COLORS.reset}`);
|
|
357
|
+
lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(model.satsCost)} sats (${pct}%)`);
|
|
358
|
+
lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(model.requests)}`);
|
|
359
|
+
lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(model.totalTokens)}`);
|
|
360
|
+
lines.push(` ${COLORS.dim}Avg:${COLORS.reset} ${formatCost(model.satsCost / model.requests)} sats/req`);
|
|
361
|
+
lines.push(` ${renderBarChart(" ", model.satsCost, maxCost, width - 6, color, Number(pct), "model-detail")}`);
|
|
362
|
+
lines.push("");
|
|
363
|
+
}
|
|
364
|
+
endBarSection("model-detail");
|
|
365
|
+
|
|
366
|
+
return renderBox(lines, width, "Model Breakdown");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function renderProviders(stats: UsageStats, width: number): string {
|
|
370
|
+
const providerStats = getProviderStats(stats.entries);
|
|
371
|
+
if (providerStats.length === 0) return renderBox(["No provider data available"], width, "Providers");
|
|
372
|
+
|
|
373
|
+
const lines: string[] = [];
|
|
374
|
+
for (const provider of providerStats) {
|
|
375
|
+
const shortUrl = provider.baseUrl.replace("https://", "").replace("http://", "");
|
|
376
|
+
lines.push(`${COLORS.cyan}${COLORS.bold}${shortUrl}${COLORS.reset}`);
|
|
377
|
+
lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(provider.requests)}`);
|
|
378
|
+
lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(provider.satsCost)} sats`);
|
|
379
|
+
lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(provider.totalTokens)}`);
|
|
380
|
+
lines.push("");
|
|
381
|
+
}
|
|
382
|
+
return renderBox(lines, width, "Provider Breakdown");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function renderTokens(stats: UsageStats, width: number): string {
|
|
386
|
+
const totals = getTotals(stats.entries);
|
|
387
|
+
const modelStats = getModelStats(stats.entries);
|
|
388
|
+
const summaryLines = [
|
|
389
|
+
`${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`,
|
|
390
|
+
`${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`,
|
|
391
|
+
`${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber(totals.totalTokens)}`,
|
|
392
|
+
`${COLORS.bold}Prompt/Completion Ratio:${COLORS.reset} ${(totals.promptTokens / Math.max(1, totals.completionTokens)).toFixed(2)}x`,
|
|
393
|
+
`${COLORS.bold}Avg Tokens/Request:${COLORS.reset} ${(totals.totalTokens / Math.max(1, stats.totalEntries)).toFixed(0)}`,
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
let output = renderBox(summaryLines, width, "Token Summary");
|
|
397
|
+
|
|
398
|
+
if (modelStats.length > 0) {
|
|
399
|
+
const tokenLines = modelStats.slice(0, 6).map((m) => {
|
|
400
|
+
const color = MODEL_COLORS[m.modelId] || MODEL_COLORS.default;
|
|
401
|
+
const promptPct = m.totalTokens > 0 ? ((m.promptTokens / m.totalTokens) * 100).toFixed(0) : "0";
|
|
402
|
+
return `${color}${m.modelId.padEnd(15)}${COLORS.reset} ${formatNumber(m.totalTokens).padStart(8)} tokens (${promptPct}% prompt)`;
|
|
403
|
+
});
|
|
404
|
+
output += "\n" + renderBox(tokenLines, width, "Tokens by Model");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const sizeBuckets = {
|
|
408
|
+
tiny: { min: 0, max: 1000, count: 0, cost: 0 },
|
|
409
|
+
small: { min: 1000, max: 10000, count: 0, cost: 0 },
|
|
410
|
+
medium: { min: 10000, max: 50000, count: 0, cost: 0 },
|
|
411
|
+
large: { min: 50000, max: 100000, count: 0, cost: 0 },
|
|
412
|
+
huge: { min: 100000, max: Infinity, count: 0, cost: 0 },
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
for (const entry of stats.entries) {
|
|
416
|
+
for (const bucket of Object.values(sizeBuckets)) {
|
|
417
|
+
if (entry.totalTokens >= bucket.min && entry.totalTokens < bucket.max) {
|
|
418
|
+
bucket.count++;
|
|
419
|
+
bucket.cost += entry.satsCost;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const sizeLines = Object.entries(sizeBuckets).map(([name, bucket]) => `${name.padEnd(6)}: ${formatReqs(bucket.count).padStart(5)} reqs, ${formatCost(bucket.cost)} sats`);
|
|
426
|
+
output += "\n" + renderBox(sizeLines, width, "Request Size Distribution");
|
|
427
|
+
return output;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function renderClients(stats: UsageStats, width: number): string {
|
|
431
|
+
const clientStats = getClientStats(stats.entries);
|
|
432
|
+
if (clientStats.length === 0) return renderBox(["No client data available (API key auth not used)"], width, "Client Breakdown");
|
|
433
|
+
|
|
434
|
+
const totalCost = getTotals(stats.entries).satsCost;
|
|
435
|
+
const maxCost = clientStats[0]!.satsCost;
|
|
436
|
+
const lines: string[] = [];
|
|
437
|
+
|
|
438
|
+
const col1 = 20; // Client
|
|
439
|
+
const col2 = 12; // Requests
|
|
440
|
+
const col3 = 24; // Cost
|
|
441
|
+
const col4 = 12; // Tokens
|
|
442
|
+
|
|
443
|
+
const hClient = "Client".padEnd(col1);
|
|
444
|
+
const hReqs = "Requests".padEnd(col2);
|
|
445
|
+
const hCost = "Cost".padEnd(col3);
|
|
446
|
+
const hTok = "Tokens".padEnd(col4);
|
|
447
|
+
lines.push(`${COLORS.bold}${hClient}${hReqs}${hCost}${hTok}Avg Cost${COLORS.reset}`);
|
|
448
|
+
lines.push(COLORS.dim + "─".repeat(Math.max(0, width - 4)) + COLORS.reset);
|
|
449
|
+
|
|
450
|
+
startBarSection("client-detail", 20); // match col1
|
|
451
|
+
for (const client of clientStats) {
|
|
452
|
+
const color = CLIENT_COLORS[client.client] || CLIENT_COLORS.default || COLORS.white;
|
|
453
|
+
const pct = totalCost > 0 ? ((client.satsCost / totalCost) * 100).toFixed(1) : "0.0";
|
|
454
|
+
const avgCostFormatted = formatCost(client.requests > 0 ? client.satsCost / client.requests : 0);
|
|
455
|
+
|
|
456
|
+
const dClient = client.client.slice(0, col1 - 1).padEnd(col1);
|
|
457
|
+
const dReqs = formatReqs(client.requests).padEnd(col2);
|
|
458
|
+
const dCost = `${formatCost(client.satsCost)} sats (${pct}%)`.padEnd(col3);
|
|
459
|
+
const dTok = formatNumber(client.totalTokens).padEnd(col4);
|
|
460
|
+
const dAvg = `${avgCostFormatted} sats/req`;
|
|
461
|
+
|
|
462
|
+
lines.push(
|
|
463
|
+
`${color}${COLORS.bold}${dClient}${COLORS.reset}` +
|
|
464
|
+
`${dReqs}` +
|
|
465
|
+
`${COLORS.green}${dCost}${COLORS.reset}` +
|
|
466
|
+
`${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`
|
|
467
|
+
);
|
|
468
|
+
lines.push(` ${renderBarChart("", client.satsCost, maxCost, width - 6, color, Number(pct), "client-detail")}`);
|
|
469
|
+
lines.push("");
|
|
470
|
+
}
|
|
471
|
+
endBarSection("client-detail");
|
|
472
|
+
|
|
473
|
+
let output = renderBox(lines, width, "Client Breakdown");
|
|
474
|
+
const clientModelMap = new Map<string, Map<string, { requests: number; satsCost: number; tokens: number }>>();
|
|
475
|
+
|
|
476
|
+
for (const entry of stats.entries) {
|
|
477
|
+
const client = entry.client || "unknown";
|
|
478
|
+
const model = entry.modelId;
|
|
479
|
+
if (!clientModelMap.has(client)) clientModelMap.set(client, new Map());
|
|
480
|
+
const modelMap = clientModelMap.get(client)!;
|
|
481
|
+
const existing = modelMap.get(model) || { requests: 0, satsCost: 0, tokens: 0 };
|
|
482
|
+
modelMap.set(model, {
|
|
483
|
+
requests: existing.requests + 1,
|
|
484
|
+
satsCost: existing.satsCost + entry.satsCost,
|
|
485
|
+
tokens: existing.tokens + entry.totalTokens,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const clientModelLines: string[] = [];
|
|
490
|
+
for (const topClient of clientStats.slice(0, 3)) {
|
|
491
|
+
const modelMap = clientModelMap.get(topClient.client);
|
|
492
|
+
if (!modelMap) continue;
|
|
493
|
+
const models = Array.from(modelMap.entries()).sort((a, b) => b[1].satsCost - a[1].satsCost).slice(0, 5);
|
|
494
|
+
clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`);
|
|
495
|
+
for (const [model, data] of models) {
|
|
496
|
+
clientModelLines.push(` ${(MODEL_COLORS[model] || MODEL_COLORS.default)}${model.padEnd(18)}${COLORS.reset} ${formatNumber(data.tokens).padEnd(8)} tokens ${formatCost(data.satsCost)} sats`);
|
|
497
|
+
}
|
|
498
|
+
clientModelLines.push("");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (clientModelLines.length > 0) {
|
|
502
|
+
output += "\n" + renderBox(clientModelLines, width, "Top Models per Client");
|
|
503
|
+
}
|
|
504
|
+
return output;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function renderRecent(stats: UsageStats, width: number): string {
|
|
508
|
+
const recentEntries = stats.entries.slice(0, 50);
|
|
509
|
+
if (recentEntries.length === 0) return renderBox(["No recent entries"], width, "Recent Requests");
|
|
510
|
+
|
|
511
|
+
const lines: string[] = [];
|
|
512
|
+
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}`);
|
|
513
|
+
lines.push(COLORS.dim + "─".repeat(width - 4) + COLORS.reset);
|
|
514
|
+
|
|
515
|
+
for (const entry of recentEntries) {
|
|
516
|
+
const time = formatTime(entry.timestamp).slice(0, 8);
|
|
517
|
+
const model = entry.modelId.slice(0, 18).padEnd(18);
|
|
518
|
+
const tokens = `${formatNumber(entry.totalTokens).padEnd(6)} (${formatNumber(entry.promptTokens)}+${formatNumber(entry.completionTokens)})`;
|
|
519
|
+
const cost = `${formatCost(entry.satsCost).padEnd(8)} sats`;
|
|
520
|
+
const provider = (entry.baseUrl || "unknown").replace("https://", "").replace("http://", "").slice(0, Math.max(0, width - 60));
|
|
521
|
+
const color = MODEL_COLORS[entry.modelId] || MODEL_COLORS.default;
|
|
522
|
+
lines.push(`${COLORS.dim}${time}${COLORS.reset} ${color}${model}${COLORS.reset} ${tokens.padEnd(10)} ${COLORS.green}${cost}${COLORS.reset} ${COLORS.dim}${provider}${COLORS.reset}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return renderBox(lines, width, `Recent Requests (${stats.entries.length} shown)`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function renderTabContent(activeTab: TabId, stats: UsageStats, balance: BalanceInfo | null, status: StatusInfo | null, width: number): string {
|
|
529
|
+
switch (activeTab) {
|
|
530
|
+
case "overview": return renderOverview(stats, balance, status, width);
|
|
531
|
+
case "today": return renderToday(stats, width);
|
|
532
|
+
case "models": return renderModels(stats, width);
|
|
533
|
+
case "providers": return renderProviders(stats, width);
|
|
534
|
+
case "tokens": return renderTokens(stats, width);
|
|
535
|
+
case "clients": return renderClients(stats, width);
|
|
536
|
+
case "recent": return renderRecent(stats, width);
|
|
537
|
+
default: return "Unknown tab";
|
|
538
|
+
}
|
|
539
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
|
2
|
+
import type { VimState } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export const vimState: VimState = {
|
|
5
|
+
scrollPos: 0,
|
|
6
|
+
searchQuery: "",
|
|
7
|
+
searchResults: [],
|
|
8
|
+
currentSearchIdx: 0,
|
|
9
|
+
isSearching: false,
|
|
10
|
+
searchReverse: false,
|
|
11
|
+
mode: "normal",
|
|
12
|
+
lastKey: "",
|
|
13
|
+
lastKeyTime: 0,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let maxScrollLines = 0;
|
|
17
|
+
|
|
18
|
+
export function clampScrollPos(contentHeight: number, viewportHeight: number): void {
|
|
19
|
+
const maxScroll = Math.max(0, contentHeight - viewportHeight);
|
|
20
|
+
vimState.scrollPos = Math.max(0, Math.min(vimState.scrollPos, maxScroll));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function applyScrollToContent(content: string, viewportHeight: number): string {
|
|
24
|
+
const lines = content.split("\n");
|
|
25
|
+
maxScrollLines = Math.max(0, lines.length - Math.max(0, viewportHeight));
|
|
26
|
+
clampScrollPos(lines.length, viewportHeight);
|
|
27
|
+
|
|
28
|
+
if (viewportHeight <= 0) {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return lines.slice(vimState.scrollPos, vimState.scrollPos + viewportHeight).join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function scrollDown(lines = 1): void {
|
|
36
|
+
vimState.scrollPos = Math.min(vimState.scrollPos + lines, maxScrollLines);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function scrollUp(lines = 1): void {
|
|
40
|
+
vimState.scrollPos = Math.max(vimState.scrollPos - lines, 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function scrollToTop(): void {
|
|
44
|
+
vimState.scrollPos = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function scrollToBottom(): void {
|
|
48
|
+
vimState.scrollPos = maxScrollLines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pageUp(): void {
|
|
52
|
+
scrollUp(15);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pageDown(): void {
|
|
56
|
+
scrollDown(15);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function startSearch(reverse = false): void {
|
|
60
|
+
vimState.isSearching = true;
|
|
61
|
+
vimState.searchReverse = reverse;
|
|
62
|
+
vimState.searchQuery = "";
|
|
63
|
+
vimState.mode = "search";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function performSearch(query: string, entries: UsageTrackingEntry[]): void {
|
|
67
|
+
vimState.searchQuery = query;
|
|
68
|
+
vimState.searchResults = [];
|
|
69
|
+
vimState.currentSearchIdx = 0;
|
|
70
|
+
|
|
71
|
+
if (!query) {
|
|
72
|
+
vimState.searchResults = [];
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lowerQuery = query.toLowerCase();
|
|
77
|
+
entries.forEach((entry, idx) => {
|
|
78
|
+
const searchable = [entry.modelId, entry.baseUrl || "", entry.client || ""].join(" ").toLowerCase();
|
|
79
|
+
if (searchable.includes(lowerQuery)) {
|
|
80
|
+
vimState.searchResults.push(idx);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function nextSearchResult(totalEntries: number): void {
|
|
86
|
+
if (vimState.searchResults.length === 0) return;
|
|
87
|
+
vimState.currentSearchIdx = (vimState.currentSearchIdx + 1) % vimState.searchResults.length;
|
|
88
|
+
vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function prevSearchResult(totalEntries: number): void {
|
|
92
|
+
if (vimState.searchResults.length === 0) return;
|
|
93
|
+
vimState.currentSearchIdx = (vimState.currentSearchIdx - 1 + vimState.searchResults.length) % vimState.searchResults.length;
|
|
94
|
+
vimState.scrollPos = Math.floor((vimState.searchResults[vimState.currentSearchIdx]! / Math.max(1, totalEntries)) * maxScrollLines);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function exitSearchMode(): void {
|
|
98
|
+
vimState.isSearching = false;
|
|
99
|
+
vimState.mode = "normal";
|
|
100
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function clearScreen(): string {
|
|
2
|
+
return "\x1b[2J\x1b[H";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function enterAlternateScreen(): string {
|
|
6
|
+
return "\x1b[?1049h";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function leaveAlternateScreen(): string {
|
|
10
|
+
return "\x1b[?1049l";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hideCursor(): string {
|
|
14
|
+
return "\x1b[?25l";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function showCursor(): string {
|
|
18
|
+
return "\x1b[?25h";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function moveCursor(row: number, col: number): string {
|
|
22
|
+
return `\x1b[${row};${col}H`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function eraseDown(): string {
|
|
26
|
+
return "\x1b[J";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getWidth(): number {
|
|
30
|
+
return process.stdout.columns || 80;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getHeight(): number {
|
|
34
|
+
return process.stdout.rows || 24;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function stripAnsi(str: string): string {
|
|
38
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
39
|
+
}
|