@thiagos1lva/opencode-token-usage-chart 0.2.3 → 0.2.7
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/dist/index.js +1 -0
- package/dist/plugins/tui-token-usage.js +846 -0
- package/package.json +20 -14
- package/index.ts +0 -1
- package/plugins/tui-token-usage.tsx +0 -861
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
import { effect as _$effect } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
2
|
+
import { createComponent as _$createComponent } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
3
|
+
import { insert as _$insert } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
4
|
+
import { memo as _$memo } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
5
|
+
import { createTextNode as _$createTextNode } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
6
|
+
import { insertNode as _$insertNode } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
7
|
+
import { setProp as _$setProp } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
8
|
+
import { createElement as _$createElement } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
9
|
+
/** @jsxImportSource @opentui/solid */
|
|
10
|
+
import { useKeyboard, useTerminalDimensions } from "opentui:runtime-module:%40opentui%2Fsolid";
|
|
11
|
+
import { createEffect, createMemo, createSignal, For, Show, onCleanup } from "opentui:runtime-module:solid-js";
|
|
12
|
+
const id = "tui-token-usage";
|
|
13
|
+
const route = "token-usage";
|
|
14
|
+
const gran = ["15min", "30min", "hour", "day", "week", "month"];
|
|
15
|
+
const metr = ["tokens", "cost", "both"];
|
|
16
|
+
const GLOBAL_CALL_OPTIONS = {
|
|
17
|
+
headers: {
|
|
18
|
+
"x-opencode-directory": "",
|
|
19
|
+
"x-opencode-workspace": ""
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const sessionAggregateCache = new Map();
|
|
23
|
+
const CACHE_VERSION = "v6";
|
|
24
|
+
function isFastMode(mode) {
|
|
25
|
+
return mode === "15min" || mode === "30min" || mode === "hour";
|
|
26
|
+
}
|
|
27
|
+
function messageLimit(mode) {
|
|
28
|
+
if (mode === "15min") return 400;
|
|
29
|
+
if (mode === "30min") return 500;
|
|
30
|
+
if (mode === "hour") return 800;
|
|
31
|
+
if (mode === "day") return 2000;
|
|
32
|
+
if (mode === "week") return 4000;
|
|
33
|
+
return 6000;
|
|
34
|
+
}
|
|
35
|
+
function sessionListLimit(mode) {
|
|
36
|
+
return isFastMode(mode) ? 5000 : 20000;
|
|
37
|
+
}
|
|
38
|
+
function count(input) {
|
|
39
|
+
if (input === "15min") return 48;
|
|
40
|
+
if (input === "30min") return 48;
|
|
41
|
+
if (input === "hour") return 24;
|
|
42
|
+
if (input === "day") return 30;
|
|
43
|
+
if (input === "week") return 20;
|
|
44
|
+
return 12;
|
|
45
|
+
}
|
|
46
|
+
function start(ts, mode) {
|
|
47
|
+
const d = new Date(ts);
|
|
48
|
+
if (mode === "15min") {
|
|
49
|
+
const minute = d.getMinutes();
|
|
50
|
+
d.setMinutes(minute - minute % 15, 0, 0);
|
|
51
|
+
return d.getTime();
|
|
52
|
+
}
|
|
53
|
+
if (mode === "30min") {
|
|
54
|
+
const minute = d.getMinutes();
|
|
55
|
+
d.setMinutes(minute - minute % 30, 0, 0);
|
|
56
|
+
return d.getTime();
|
|
57
|
+
}
|
|
58
|
+
if (mode === "hour") {
|
|
59
|
+
d.setMinutes(0, 0, 0);
|
|
60
|
+
return d.getTime();
|
|
61
|
+
}
|
|
62
|
+
if (mode === "day") {
|
|
63
|
+
d.setHours(0, 0, 0, 0);
|
|
64
|
+
return d.getTime();
|
|
65
|
+
}
|
|
66
|
+
if (mode === "week") {
|
|
67
|
+
d.setHours(0, 0, 0, 0);
|
|
68
|
+
const day = (d.getDay() + 6) % 7;
|
|
69
|
+
d.setDate(d.getDate() - day);
|
|
70
|
+
return d.getTime();
|
|
71
|
+
}
|
|
72
|
+
d.setHours(0, 0, 0, 0);
|
|
73
|
+
d.setDate(1);
|
|
74
|
+
return d.getTime();
|
|
75
|
+
}
|
|
76
|
+
function label(ts, mode) {
|
|
77
|
+
const d = new Date(ts);
|
|
78
|
+
if (mode === "15min" || mode === "30min") {
|
|
79
|
+
return d.toLocaleString(undefined, {
|
|
80
|
+
hour: "2-digit",
|
|
81
|
+
minute: "2-digit",
|
|
82
|
+
day: "2-digit",
|
|
83
|
+
month: "2-digit"
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (mode === "hour") return d.toLocaleString(undefined, {
|
|
87
|
+
hour: "2-digit",
|
|
88
|
+
day: "2-digit",
|
|
89
|
+
month: "2-digit"
|
|
90
|
+
});
|
|
91
|
+
if (mode === "day") return d.toLocaleDateString(undefined, {
|
|
92
|
+
day: "2-digit",
|
|
93
|
+
month: "2-digit"
|
|
94
|
+
});
|
|
95
|
+
if (mode === "week") {
|
|
96
|
+
const w = week(d);
|
|
97
|
+
return `W${w.number} ${w.year}`;
|
|
98
|
+
}
|
|
99
|
+
return d.toLocaleDateString(undefined, {
|
|
100
|
+
month: "short",
|
|
101
|
+
year: "2-digit"
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function week(d) {
|
|
105
|
+
const x = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
|
106
|
+
x.setUTCDate(x.getUTCDate() + 4 - (x.getUTCDay() || 7));
|
|
107
|
+
const y = new Date(Date.UTC(x.getUTCFullYear(), 0, 1));
|
|
108
|
+
return {
|
|
109
|
+
number: Math.ceil(((x.getTime() - y.getTime()) / 86400000 + 1) / 7),
|
|
110
|
+
year: x.getUTCFullYear()
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function add(ts, mode, amount) {
|
|
114
|
+
const d = new Date(ts);
|
|
115
|
+
if (mode === "15min") {
|
|
116
|
+
d.setMinutes(d.getMinutes() + amount * 15);
|
|
117
|
+
return d.getTime();
|
|
118
|
+
}
|
|
119
|
+
if (mode === "30min") {
|
|
120
|
+
d.setMinutes(d.getMinutes() + amount * 30);
|
|
121
|
+
return d.getTime();
|
|
122
|
+
}
|
|
123
|
+
if (mode === "hour") {
|
|
124
|
+
d.setHours(d.getHours() + amount);
|
|
125
|
+
return d.getTime();
|
|
126
|
+
}
|
|
127
|
+
if (mode === "day") {
|
|
128
|
+
d.setDate(d.getDate() + amount);
|
|
129
|
+
return d.getTime();
|
|
130
|
+
}
|
|
131
|
+
if (mode === "week") {
|
|
132
|
+
d.setDate(d.getDate() + amount * 7);
|
|
133
|
+
return d.getTime();
|
|
134
|
+
}
|
|
135
|
+
d.setMonth(d.getMonth() + amount);
|
|
136
|
+
return d.getTime();
|
|
137
|
+
}
|
|
138
|
+
function buildRows(mode, now = Date.now()) {
|
|
139
|
+
const n = count(mode);
|
|
140
|
+
const end = start(now, mode);
|
|
141
|
+
const first = add(end, mode, -(n - 1));
|
|
142
|
+
return Array.from({
|
|
143
|
+
length: n
|
|
144
|
+
}, (_, i) => {
|
|
145
|
+
const key = add(first, mode, i);
|
|
146
|
+
return {
|
|
147
|
+
key,
|
|
148
|
+
label: label(key, mode),
|
|
149
|
+
tokens: 0,
|
|
150
|
+
cost: 0
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function tok(msg) {
|
|
155
|
+
return msg.input + msg.output + msg.reasoning + msg.cache.read + msg.cache.write;
|
|
156
|
+
}
|
|
157
|
+
function sessionStamp(session) {
|
|
158
|
+
if (!session || typeof session !== "object") return "";
|
|
159
|
+
const value = session;
|
|
160
|
+
const id = typeof value.id === "string" ? value.id : "";
|
|
161
|
+
const updated = typeof value.time?.updated === "number" ? value.time.updated : undefined;
|
|
162
|
+
const created = typeof value.time?.created === "number" ? value.time.created : undefined;
|
|
163
|
+
const version = typeof value.version === "string" ? value.version : "";
|
|
164
|
+
return `${id}:${updated ?? created ?? ""}:${version}`;
|
|
165
|
+
}
|
|
166
|
+
async function aggregateSession(client, sessionID, directory, stamp, mode, range, options) {
|
|
167
|
+
const key = `${directory ?? "default"}:${sessionID}:${mode}:${range.start}:${range.end}`;
|
|
168
|
+
const cached = sessionAggregateCache.get(key);
|
|
169
|
+
if (cached && cached.stamp === stamp) {
|
|
170
|
+
return {
|
|
171
|
+
...cached,
|
|
172
|
+
stats: {
|
|
173
|
+
...cached.stats,
|
|
174
|
+
cached: true
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (options.shouldStop?.()) {
|
|
179
|
+
return {
|
|
180
|
+
stamp,
|
|
181
|
+
bins: new Map(),
|
|
182
|
+
total: {
|
|
183
|
+
tokens: 0,
|
|
184
|
+
cost: 0
|
|
185
|
+
},
|
|
186
|
+
stats: {
|
|
187
|
+
messages: 0,
|
|
188
|
+
assistant: 0,
|
|
189
|
+
inRange: 0,
|
|
190
|
+
cached: false
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
let messageError;
|
|
195
|
+
const messages = await client.session.messages({
|
|
196
|
+
sessionID,
|
|
197
|
+
directory,
|
|
198
|
+
limit: messageLimit(mode)
|
|
199
|
+
}, GLOBAL_CALL_OPTIONS).then(x => x.data ?? []).catch(error => {
|
|
200
|
+
messageError = error instanceof Error ? error.message : String(error);
|
|
201
|
+
return [];
|
|
202
|
+
});
|
|
203
|
+
const bins = new Map();
|
|
204
|
+
let total = {
|
|
205
|
+
tokens: 0,
|
|
206
|
+
cost: 0
|
|
207
|
+
};
|
|
208
|
+
let assistantCount = 0;
|
|
209
|
+
let inRangeCount = 0;
|
|
210
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
211
|
+
if (options.shouldStop?.()) break;
|
|
212
|
+
const info = messages[i].info;
|
|
213
|
+
if (info.role !== "assistant") continue;
|
|
214
|
+
assistantCount++;
|
|
215
|
+
const created = info.time.created;
|
|
216
|
+
if (created >= range.end) continue;
|
|
217
|
+
if (created < range.start) continue;
|
|
218
|
+
inRangeCount++;
|
|
219
|
+
const bucket = start(created, mode);
|
|
220
|
+
const value = bins.get(bucket) ?? {
|
|
221
|
+
tokens: 0,
|
|
222
|
+
cost: 0
|
|
223
|
+
};
|
|
224
|
+
const tokens = tok(info.tokens);
|
|
225
|
+
value.tokens += tokens;
|
|
226
|
+
value.cost += info.cost;
|
|
227
|
+
bins.set(bucket, value);
|
|
228
|
+
total.tokens += tokens;
|
|
229
|
+
total.cost += info.cost;
|
|
230
|
+
}
|
|
231
|
+
const out = {
|
|
232
|
+
stamp,
|
|
233
|
+
bins,
|
|
234
|
+
total,
|
|
235
|
+
stats: {
|
|
236
|
+
messages: messages.length,
|
|
237
|
+
assistant: assistantCount,
|
|
238
|
+
inRange: inRangeCount,
|
|
239
|
+
cached: false,
|
|
240
|
+
error: messageError
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
sessionAggregateCache.set(key, out);
|
|
244
|
+
if (sessionAggregateCache.size > 500) {
|
|
245
|
+
const oldest = sessionAggregateCache.keys().next().value;
|
|
246
|
+
if (oldest) sessionAggregateCache.delete(oldest);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
async function load(api, mode, scope, ref, options = {}) {
|
|
251
|
+
const debugLines = [];
|
|
252
|
+
const apiWithScopes = api;
|
|
253
|
+
const workspaceIDs = Array.from(new Set((apiWithScopes.state?.workspace?.list?.() ?? []).map(item => item?.id).filter(item => typeof item === "string" && item.length > 0)));
|
|
254
|
+
const allScopeRef = workspaceIDs.length > 0 ? `all:${workspaceIDs.sort().join(",")}` : "all";
|
|
255
|
+
const scopeRef = scope === "session" ? ref.sessionID ?? "none" : scope === "workspace" ? ref.workspaceID ?? "none" : "all";
|
|
256
|
+
const key = `token-usage-cache:${CACHE_VERSION}:${mode}:${scope}:${scope === "all" ? allScopeRef : scopeRef}`;
|
|
257
|
+
const hit = api.kv.get(key, undefined);
|
|
258
|
+
if (!options.force && hit && Date.now() - hit.time < 5 * 60 * 1000) {
|
|
259
|
+
return {
|
|
260
|
+
...hit.data,
|
|
261
|
+
debug: {
|
|
262
|
+
lines: [...(hit.data.debug?.lines ?? []), `cache hit key=${key}`, `cache age ms=${Date.now() - hit.time}`]
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const rows = buildRows(mode);
|
|
267
|
+
const idx = new Map(rows.map((item, i) => [item.key, i]));
|
|
268
|
+
const range = {
|
|
269
|
+
start: rows[0]?.key ?? 0,
|
|
270
|
+
end: add(rows[rows.length - 1]?.key ?? 0, mode, 1)
|
|
271
|
+
};
|
|
272
|
+
debugLines.push(`cache miss key=${key}`);
|
|
273
|
+
debugLines.push(`scope=${scope} mode=${mode}`);
|
|
274
|
+
debugLines.push(`workspace ids=${workspaceIDs.length}`);
|
|
275
|
+
debugLines.push(`window start=${new Date(range.start).toISOString()} end=${new Date(range.end).toISOString()}`);
|
|
276
|
+
const clientSources = [];
|
|
277
|
+
if (scope === "workspace") {
|
|
278
|
+
if (ref.workspaceID && apiWithScopes.scopedClient) {
|
|
279
|
+
clientSources.push({
|
|
280
|
+
key: `workspace:${ref.workspaceID}`,
|
|
281
|
+
client: apiWithScopes.scopedClient(ref.workspaceID)
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
clientSources.push({
|
|
285
|
+
key: "workspace:default",
|
|
286
|
+
client: api.client
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
} else if (scope === "all") {
|
|
290
|
+
clientSources.push({
|
|
291
|
+
key: "all:default",
|
|
292
|
+
client: api.client
|
|
293
|
+
});
|
|
294
|
+
if (apiWithScopes.scopedClient) {
|
|
295
|
+
workspaceIDs.forEach(workspaceID => {
|
|
296
|
+
clientSources.push({
|
|
297
|
+
key: `all:${workspaceID}`,
|
|
298
|
+
client: apiWithScopes.scopedClient?.(workspaceID) ?? api.client
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
clientSources.push({
|
|
304
|
+
key: "session",
|
|
305
|
+
client: api.client
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
debugLines.push(`client sources=${clientSources.map(item => item.key).join(",")}`);
|
|
309
|
+
let sessions = [];
|
|
310
|
+
if (scope === "session") {
|
|
311
|
+
if (!ref.sessionID) {
|
|
312
|
+
return {
|
|
313
|
+
rows,
|
|
314
|
+
total: {
|
|
315
|
+
tokens: 0,
|
|
316
|
+
cost: 0
|
|
317
|
+
},
|
|
318
|
+
debug: {
|
|
319
|
+
lines: [...debugLines, "missing sessionID for session scope"]
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
sessions = [{
|
|
324
|
+
id: ref.sessionID,
|
|
325
|
+
stamp: ref.sessionID,
|
|
326
|
+
client: api.client,
|
|
327
|
+
directory: undefined
|
|
328
|
+
}];
|
|
329
|
+
} else {
|
|
330
|
+
const dedup = new Map();
|
|
331
|
+
const globalList = await api.client.session.list({
|
|
332
|
+
limit: sessionListLimit(mode)
|
|
333
|
+
}, GLOBAL_CALL_OPTIONS).then(x => x.data ?? []).catch(() => []);
|
|
334
|
+
debugLines.push(`sessions from global override: ${globalList.length}`);
|
|
335
|
+
globalList.forEach(item => {
|
|
336
|
+
if (!item?.id) return;
|
|
337
|
+
if (dedup.has(item.id)) return;
|
|
338
|
+
dedup.set(item.id, {
|
|
339
|
+
id: item.id,
|
|
340
|
+
stamp: sessionStamp(item) || item.id,
|
|
341
|
+
client: api.client,
|
|
342
|
+
directory: undefined
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
const projects = await api.client.project.list(undefined, GLOBAL_CALL_OPTIONS).then(x => x.data ?? []).catch(() => []);
|
|
346
|
+
debugLines.push(`projects discovered: ${projects.length}`);
|
|
347
|
+
for (const project of projects) {
|
|
348
|
+
if (!project?.worktree) continue;
|
|
349
|
+
const list = await api.client.session.list({
|
|
350
|
+
directory: project.worktree,
|
|
351
|
+
limit: sessionListLimit(mode)
|
|
352
|
+
}, GLOBAL_CALL_OPTIONS).then(x => x.data ?? []).catch(() => []);
|
|
353
|
+
debugLines.push(`sessions from project ${project.worktree}: ${list.length}`);
|
|
354
|
+
list.forEach(item => {
|
|
355
|
+
if (!item?.id) return;
|
|
356
|
+
if (dedup.has(item.id)) return;
|
|
357
|
+
dedup.set(item.id, {
|
|
358
|
+
id: item.id,
|
|
359
|
+
stamp: sessionStamp(item) || item.id,
|
|
360
|
+
client: api.client,
|
|
361
|
+
directory: project.worktree
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
for (const source of clientSources) {
|
|
366
|
+
if (options.shouldStop?.()) break;
|
|
367
|
+
const list = await source.client.session.list({
|
|
368
|
+
limit: sessionListLimit(mode)
|
|
369
|
+
}, GLOBAL_CALL_OPTIONS).then(x => x.data ?? []).catch(() => []);
|
|
370
|
+
debugLines.push(`sessions from ${source.key}: ${list.length}`);
|
|
371
|
+
list.forEach(item => {
|
|
372
|
+
if (!item?.id) return;
|
|
373
|
+
if (dedup.has(item.id)) return;
|
|
374
|
+
dedup.set(item.id, {
|
|
375
|
+
id: item.id,
|
|
376
|
+
stamp: sessionStamp(item) || item.id,
|
|
377
|
+
client: source.client,
|
|
378
|
+
directory: undefined
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
sessions = Array.from(dedup.values());
|
|
383
|
+
}
|
|
384
|
+
debugLines.push(`dedup sessions=${sessions.length}`);
|
|
385
|
+
const size = isFastMode(mode) ? 6 : 10;
|
|
386
|
+
let total = {
|
|
387
|
+
tokens: 0,
|
|
388
|
+
cost: 0
|
|
389
|
+
};
|
|
390
|
+
let totalMessagesScanned = 0;
|
|
391
|
+
let totalAssistantMessages = 0;
|
|
392
|
+
let totalInRangeMessages = 0;
|
|
393
|
+
let cachedSessionCount = 0;
|
|
394
|
+
let sessionFetchErrors = 0;
|
|
395
|
+
for (let i = 0; i < sessions.length; i += size) {
|
|
396
|
+
if (options.shouldStop?.()) break;
|
|
397
|
+
const part = sessions.slice(i, i + size);
|
|
398
|
+
const packs = await Promise.all(part.map(session => aggregateSession(session.client, session.id, session.directory, session.stamp, mode, range, options).catch(() => ({
|
|
399
|
+
stamp: session.stamp,
|
|
400
|
+
bins: new Map(),
|
|
401
|
+
total: {
|
|
402
|
+
tokens: 0,
|
|
403
|
+
cost: 0
|
|
404
|
+
},
|
|
405
|
+
stats: {
|
|
406
|
+
messages: 0,
|
|
407
|
+
assistant: 0,
|
|
408
|
+
inRange: 0,
|
|
409
|
+
cached: false,
|
|
410
|
+
error: "aggregateSession failed"
|
|
411
|
+
}
|
|
412
|
+
}))));
|
|
413
|
+
packs.forEach(pack => {
|
|
414
|
+
pack.bins.forEach((value, bucket) => {
|
|
415
|
+
const rowIndex = idx.get(bucket);
|
|
416
|
+
if (rowIndex === undefined) return;
|
|
417
|
+
rows[rowIndex].tokens += value.tokens;
|
|
418
|
+
rows[rowIndex].cost += value.cost;
|
|
419
|
+
});
|
|
420
|
+
total.tokens += pack.total.tokens;
|
|
421
|
+
total.cost += pack.total.cost;
|
|
422
|
+
totalMessagesScanned += pack.stats.messages;
|
|
423
|
+
totalAssistantMessages += pack.stats.assistant;
|
|
424
|
+
totalInRangeMessages += pack.stats.inRange;
|
|
425
|
+
if (pack.stats.cached) cachedSessionCount++;
|
|
426
|
+
if (pack.stats.error) sessionFetchErrors++;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const rowsWithData = rows.reduce((count, row) => row.tokens > 0 || row.cost > 0 ? count + 1 : count, 0);
|
|
430
|
+
debugLines.push(`messages scanned=${totalMessagesScanned}`);
|
|
431
|
+
debugLines.push(`assistant messages=${totalAssistantMessages}`);
|
|
432
|
+
debugLines.push(`assistant in window=${totalInRangeMessages}`);
|
|
433
|
+
debugLines.push(`session cache hits=${cachedSessionCount}`);
|
|
434
|
+
debugLines.push(`session fetch errors=${sessionFetchErrors}`);
|
|
435
|
+
debugLines.push(`rows with data=${rowsWithData}/${rows.length}`);
|
|
436
|
+
debugLines.push(`total tokens=${Math.round(total.tokens)} total cost=${total.cost.toFixed(4)}`);
|
|
437
|
+
const out = {
|
|
438
|
+
rows,
|
|
439
|
+
total,
|
|
440
|
+
debug: {
|
|
441
|
+
lines: debugLines
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
api.kv.set(key, {
|
|
445
|
+
time: Date.now(),
|
|
446
|
+
data: out
|
|
447
|
+
});
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
function fmt(input) {
|
|
451
|
+
if (input >= 1000000) return `${(input / 1000000).toFixed(1)}M`;
|
|
452
|
+
if (input >= 1000) return `${(input / 1000).toFixed(1)}K`;
|
|
453
|
+
return `${Math.round(input)}`;
|
|
454
|
+
}
|
|
455
|
+
function bar(size) {
|
|
456
|
+
if (size <= 0) return "";
|
|
457
|
+
return "#".repeat(size);
|
|
458
|
+
}
|
|
459
|
+
function barWith(size, char) {
|
|
460
|
+
if (size <= 0) return "";
|
|
461
|
+
return char.repeat(size);
|
|
462
|
+
}
|
|
463
|
+
function next(all, cur, dir) {
|
|
464
|
+
const i = all.indexOf(cur);
|
|
465
|
+
if (i === -1) return all[0];
|
|
466
|
+
const len = all.length;
|
|
467
|
+
return all[(i + dir + len) % len];
|
|
468
|
+
}
|
|
469
|
+
function parseBackTarget(input) {
|
|
470
|
+
if (!input || typeof input !== "object") return {
|
|
471
|
+
name: "home"
|
|
472
|
+
};
|
|
473
|
+
const data = input;
|
|
474
|
+
if (!data.back || typeof data.back !== "object") return {
|
|
475
|
+
name: "home"
|
|
476
|
+
};
|
|
477
|
+
const back = data.back;
|
|
478
|
+
if (back.name === "session") {
|
|
479
|
+
const params = back.params;
|
|
480
|
+
if (params && typeof params.sessionID === "string" && params.sessionID.length > 0) {
|
|
481
|
+
return {
|
|
482
|
+
name: "session",
|
|
483
|
+
params: {
|
|
484
|
+
sessionID: params.sessionID
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
name: "home"
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function View(props) {
|
|
494
|
+
const dim = useTerminalDimensions();
|
|
495
|
+
const [mode, setMode] = createSignal("day");
|
|
496
|
+
const [kind, setKind] = createSignal("tokens");
|
|
497
|
+
const [scope, setScope] = createSignal(props.back.name === "session" ? "session" : "all");
|
|
498
|
+
const [debug, setDebug] = createSignal(false);
|
|
499
|
+
const [busy, setBusy] = createSignal(true);
|
|
500
|
+
const [err, setErr] = createSignal();
|
|
501
|
+
const [data, setData] = createSignal({
|
|
502
|
+
rows: [],
|
|
503
|
+
total: {
|
|
504
|
+
tokens: 0,
|
|
505
|
+
cost: 0
|
|
506
|
+
},
|
|
507
|
+
debug: {
|
|
508
|
+
lines: []
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
const [lastRefreshAt, setLastRefreshAt] = createSignal();
|
|
512
|
+
let requestID = 0;
|
|
513
|
+
let disposed = false;
|
|
514
|
+
onCleanup(() => {
|
|
515
|
+
disposed = true;
|
|
516
|
+
});
|
|
517
|
+
const workspaceID = () => {
|
|
518
|
+
const apiWithWorkspace = props.api;
|
|
519
|
+
return apiWithWorkspace.workspace?.current?.();
|
|
520
|
+
};
|
|
521
|
+
const scopeList = createMemo(() => {
|
|
522
|
+
const out = ["all"];
|
|
523
|
+
if (workspaceID()) out.push("workspace");
|
|
524
|
+
if (props.back.name === "session") out.push("session");
|
|
525
|
+
return out;
|
|
526
|
+
});
|
|
527
|
+
const pull = (force = false) => {
|
|
528
|
+
const id = ++requestID;
|
|
529
|
+
setBusy(true);
|
|
530
|
+
setErr(undefined);
|
|
531
|
+
load(props.api, mode(), scope(), {
|
|
532
|
+
sessionID: props.back.name === "session" ? props.back.params.sessionID : undefined,
|
|
533
|
+
workspaceID: workspaceID()
|
|
534
|
+
}, {
|
|
535
|
+
force,
|
|
536
|
+
shouldStop: () => disposed || id !== requestID || props.api.route.current.name !== route
|
|
537
|
+
}).then(value => {
|
|
538
|
+
if (disposed || id !== requestID) return;
|
|
539
|
+
setData(value);
|
|
540
|
+
setLastRefreshAt(Date.now());
|
|
541
|
+
}).catch(e => {
|
|
542
|
+
if (disposed || id !== requestID) return;
|
|
543
|
+
setErr(e instanceof Error ? e.message : String(e));
|
|
544
|
+
}).finally(() => {
|
|
545
|
+
if (disposed || id !== requestID) return;
|
|
546
|
+
setBusy(false);
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
const view = createMemo(() => {
|
|
550
|
+
const rows = data().rows;
|
|
551
|
+
const maxTokens = rows.reduce((acc, item) => Math.max(acc, item.tokens), 0);
|
|
552
|
+
const maxCost = rows.reduce((acc, item) => Math.max(acc, item.cost), 0);
|
|
553
|
+
const both = kind() === "both";
|
|
554
|
+
const width = both ? Math.max(8, Math.floor(dim().width * 0.18)) : Math.max(12, Math.floor(dim().width * 0.38));
|
|
555
|
+
return rows.map(item => {
|
|
556
|
+
const tokenSize = maxTokens <= 0 ? 0 : Math.max(1, Math.round(item.tokens / maxTokens * width));
|
|
557
|
+
const costSize = maxCost <= 0 ? 0 : Math.max(1, Math.round(item.cost / maxCost * width));
|
|
558
|
+
const size = kind() === "cost" ? costSize : kind() === "tokens" ? tokenSize : 0;
|
|
559
|
+
return {
|
|
560
|
+
...item,
|
|
561
|
+
size,
|
|
562
|
+
tokenSize,
|
|
563
|
+
costSize
|
|
564
|
+
};
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
useKeyboard(evt => {
|
|
568
|
+
if (props.api.route.current.name !== route) return;
|
|
569
|
+
const key = (evt.name ?? "").toLowerCase();
|
|
570
|
+
if (evt.name === "escape") {
|
|
571
|
+
evt.preventDefault();
|
|
572
|
+
evt.stopPropagation();
|
|
573
|
+
if (props.back.name === "session") {
|
|
574
|
+
props.api.route.navigate("session", props.back.params);
|
|
575
|
+
} else {
|
|
576
|
+
props.api.route.navigate("home");
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (key === "r" || key === "f5" || evt.ctrl && key === "r") {
|
|
581
|
+
evt.preventDefault();
|
|
582
|
+
evt.stopPropagation();
|
|
583
|
+
pull(true);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (evt.name === "d") {
|
|
587
|
+
evt.preventDefault();
|
|
588
|
+
evt.stopPropagation();
|
|
589
|
+
setDebug(value => !value);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (evt.name === "s") {
|
|
593
|
+
evt.preventDefault();
|
|
594
|
+
evt.stopPropagation();
|
|
595
|
+
setScope(x => next(scopeList(), x, 1));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (evt.name === "tab" || evt.name === "right" || evt.name === "l") {
|
|
599
|
+
evt.preventDefault();
|
|
600
|
+
evt.stopPropagation();
|
|
601
|
+
setMode(x => next(gran, x, 1));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (evt.name === "left" || evt.name === "h") {
|
|
605
|
+
evt.preventDefault();
|
|
606
|
+
evt.stopPropagation();
|
|
607
|
+
setMode(x => next(gran, x, -1));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (evt.name === "up" || evt.name === "k") {
|
|
611
|
+
evt.preventDefault();
|
|
612
|
+
evt.stopPropagation();
|
|
613
|
+
setKind(x => next(metr, x, 1));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (evt.name === "down" || evt.name === "j") {
|
|
617
|
+
evt.preventDefault();
|
|
618
|
+
evt.stopPropagation();
|
|
619
|
+
setKind(x => next(metr, x, -1));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
createEffect(() => {
|
|
624
|
+
mode();
|
|
625
|
+
scope();
|
|
626
|
+
pull();
|
|
627
|
+
});
|
|
628
|
+
createEffect(() => {
|
|
629
|
+
const all = scopeList();
|
|
630
|
+
const cur = scope();
|
|
631
|
+
if (!all.includes(cur)) {
|
|
632
|
+
setScope(all[0] ?? "all");
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
const money = new Intl.NumberFormat("en-US", {
|
|
636
|
+
style: "currency",
|
|
637
|
+
currency: "USD"
|
|
638
|
+
});
|
|
639
|
+
return (() => {
|
|
640
|
+
var _el$ = _$createElement("box"),
|
|
641
|
+
_el$2 = _$createElement("text"),
|
|
642
|
+
_el$3 = _$createElement("b"),
|
|
643
|
+
_el$5 = _$createElement("text"),
|
|
644
|
+
_el$6 = _$createTextNode(`window: `),
|
|
645
|
+
_el$7 = _$createTextNode(` | metric: `),
|
|
646
|
+
_el$8 = _$createTextNode(` | scope: `),
|
|
647
|
+
_el$9 = _$createTextNode(` | debug: `),
|
|
648
|
+
_el$0 = _$createTextNode(` | keys: tab/left/right window, up/down metric, s scope, r/ctrl+r/f5 refresh, d debug, esc back`),
|
|
649
|
+
_el$13 = _$createElement("box"),
|
|
650
|
+
_el$14 = _$createElement("text"),
|
|
651
|
+
_el$15 = _$createTextNode(`total tokens: `),
|
|
652
|
+
_el$16 = _$createElement("text"),
|
|
653
|
+
_el$17 = _$createTextNode(`total cost: `);
|
|
654
|
+
_$insertNode(_el$, _el$2);
|
|
655
|
+
_$insertNode(_el$, _el$5);
|
|
656
|
+
_$insertNode(_el$, _el$13);
|
|
657
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
658
|
+
_$setProp(_el$, "paddingLeft", 2);
|
|
659
|
+
_$setProp(_el$, "paddingRight", 2);
|
|
660
|
+
_$setProp(_el$, "paddingTop", 1);
|
|
661
|
+
_$setProp(_el$, "paddingBottom", 1);
|
|
662
|
+
_$setProp(_el$, "gap", 1);
|
|
663
|
+
_$insertNode(_el$2, _el$3);
|
|
664
|
+
_$insertNode(_el$3, _$createTextNode(`Token Usage Chart`));
|
|
665
|
+
_$insertNode(_el$5, _el$6);
|
|
666
|
+
_$insertNode(_el$5, _el$7);
|
|
667
|
+
_$insertNode(_el$5, _el$8);
|
|
668
|
+
_$insertNode(_el$5, _el$9);
|
|
669
|
+
_$insertNode(_el$5, _el$0);
|
|
670
|
+
_$insert(_el$5, mode, _el$7);
|
|
671
|
+
_$insert(_el$5, kind, _el$8);
|
|
672
|
+
_$insert(_el$5, scope, _el$9);
|
|
673
|
+
_$insert(_el$5, () => debug() ? "on" : "off", _el$0);
|
|
674
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
675
|
+
get when() {
|
|
676
|
+
return lastRefreshAt();
|
|
677
|
+
},
|
|
678
|
+
children: ts => (() => {
|
|
679
|
+
var _el$21 = _$createElement("text"),
|
|
680
|
+
_el$22 = _$createTextNode(`last refresh: `);
|
|
681
|
+
_$insertNode(_el$21, _el$22);
|
|
682
|
+
_$insert(_el$21, () => new Date(ts()).toLocaleTimeString(), null);
|
|
683
|
+
_$effect(_$p => _$setProp(_el$21, "fg", props.api.theme.current.textMuted, _$p));
|
|
684
|
+
return _el$21;
|
|
685
|
+
})()
|
|
686
|
+
}), _el$13);
|
|
687
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
688
|
+
get when() {
|
|
689
|
+
return busy();
|
|
690
|
+
},
|
|
691
|
+
get children() {
|
|
692
|
+
var _el$1 = _$createElement("text");
|
|
693
|
+
_$insertNode(_el$1, _$createTextNode(`Loading usage...`));
|
|
694
|
+
_$effect(_$p => _$setProp(_el$1, "fg", props.api.theme.current.textMuted, _$p));
|
|
695
|
+
return _el$1;
|
|
696
|
+
}
|
|
697
|
+
}), _el$13);
|
|
698
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
699
|
+
get when() {
|
|
700
|
+
return err();
|
|
701
|
+
},
|
|
702
|
+
children: item => (() => {
|
|
703
|
+
var _el$23 = _$createElement("text"),
|
|
704
|
+
_el$24 = _$createTextNode(`Error: `);
|
|
705
|
+
_$insertNode(_el$23, _el$24);
|
|
706
|
+
_$insert(_el$23, item, null);
|
|
707
|
+
_$effect(_$p => _$setProp(_el$23, "fg", props.api.theme.current.error, _$p));
|
|
708
|
+
return _el$23;
|
|
709
|
+
})()
|
|
710
|
+
}), _el$13);
|
|
711
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
712
|
+
get when() {
|
|
713
|
+
return _$memo(() => !!(!busy() && !err()))() && view().length === 0;
|
|
714
|
+
},
|
|
715
|
+
get children() {
|
|
716
|
+
var _el$11 = _$createElement("text");
|
|
717
|
+
_$insertNode(_el$11, _$createTextNode(`No data found.`));
|
|
718
|
+
_$effect(_$p => _$setProp(_el$11, "fg", props.api.theme.current.textMuted, _$p));
|
|
719
|
+
return _el$11;
|
|
720
|
+
}
|
|
721
|
+
}), _el$13);
|
|
722
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
723
|
+
get when() {
|
|
724
|
+
return _$memo(() => !!(!busy() && !err()))() && view().length > 0;
|
|
725
|
+
},
|
|
726
|
+
get children() {
|
|
727
|
+
return _$createComponent(For, {
|
|
728
|
+
get each() {
|
|
729
|
+
return view();
|
|
730
|
+
},
|
|
731
|
+
children: item => (() => {
|
|
732
|
+
var _el$25 = _$createElement("text");
|
|
733
|
+
_$setProp(_el$25, "wrapMode", "none");
|
|
734
|
+
_$insert(_el$25, _$createComponent(Show, {
|
|
735
|
+
get when() {
|
|
736
|
+
return kind() === "both";
|
|
737
|
+
},
|
|
738
|
+
get fallback() {
|
|
739
|
+
return `${item.label.padEnd(11)} ${bar(item.size)} ${kind() === "cost" ? money.format(item.cost) : fmt(item.tokens)}`;
|
|
740
|
+
},
|
|
741
|
+
get children() {
|
|
742
|
+
return [_$memo(() => item.label.padEnd(11)), " T:", _$memo(() => barWith(item.tokenSize, "#")), " ", _$memo(() => fmt(item.tokens)), " C:", _$memo(() => barWith(item.costSize, "=")), " ", _$memo(() => money.format(item.cost))];
|
|
743
|
+
}
|
|
744
|
+
}));
|
|
745
|
+
_$effect(_$p => _$setProp(_el$25, "fg", props.api.theme.current.textMuted, _$p));
|
|
746
|
+
return _el$25;
|
|
747
|
+
})()
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}), _el$13);
|
|
751
|
+
_$insertNode(_el$13, _el$14);
|
|
752
|
+
_$insertNode(_el$13, _el$16);
|
|
753
|
+
_$setProp(_el$13, "flexDirection", "row");
|
|
754
|
+
_$setProp(_el$13, "gap", 3);
|
|
755
|
+
_$insertNode(_el$14, _el$15);
|
|
756
|
+
_$insert(_el$14, () => fmt(data().total.tokens), null);
|
|
757
|
+
_$insertNode(_el$16, _el$17);
|
|
758
|
+
_$insert(_el$16, () => money.format(data().total.cost), null);
|
|
759
|
+
_$insert(_el$, _$createComponent(Show, {
|
|
760
|
+
get when() {
|
|
761
|
+
return _$memo(() => !!debug())() && data().debug.lines.length > 0;
|
|
762
|
+
},
|
|
763
|
+
get children() {
|
|
764
|
+
var _el$18 = _$createElement("box"),
|
|
765
|
+
_el$19 = _$createElement("text");
|
|
766
|
+
_$insertNode(_el$18, _el$19);
|
|
767
|
+
_$setProp(_el$18, "flexDirection", "column");
|
|
768
|
+
_$setProp(_el$18, "marginTop", 1);
|
|
769
|
+
_$insertNode(_el$19, _$createTextNode(`Debug`));
|
|
770
|
+
_$insert(_el$18, _$createComponent(For, {
|
|
771
|
+
get each() {
|
|
772
|
+
return data().debug.lines;
|
|
773
|
+
},
|
|
774
|
+
children: line => (() => {
|
|
775
|
+
var _el$26 = _$createElement("text"),
|
|
776
|
+
_el$27 = _$createTextNode(`- `);
|
|
777
|
+
_$insertNode(_el$26, _el$27);
|
|
778
|
+
_$insert(_el$26, line, null);
|
|
779
|
+
_$effect(_$p => _$setProp(_el$26, "fg", props.api.theme.current.textMuted, _$p));
|
|
780
|
+
return _el$26;
|
|
781
|
+
})()
|
|
782
|
+
}), null);
|
|
783
|
+
_$effect(_$p => _$setProp(_el$19, "fg", props.api.theme.current.info, _$p));
|
|
784
|
+
return _el$18;
|
|
785
|
+
}
|
|
786
|
+
}), null);
|
|
787
|
+
_$effect(_p$ => {
|
|
788
|
+
var _v$ = props.api.theme.current.text,
|
|
789
|
+
_v$2 = props.api.theme.current.textMuted,
|
|
790
|
+
_v$3 = props.api.theme.current.textMuted,
|
|
791
|
+
_v$4 = props.api.theme.current.textMuted;
|
|
792
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$2, "fg", _v$, _p$.e));
|
|
793
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$5, "fg", _v$2, _p$.t));
|
|
794
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$14, "fg", _v$3, _p$.a));
|
|
795
|
+
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$16, "fg", _v$4, _p$.o));
|
|
796
|
+
return _p$;
|
|
797
|
+
}, {
|
|
798
|
+
e: undefined,
|
|
799
|
+
t: undefined,
|
|
800
|
+
a: undefined,
|
|
801
|
+
o: undefined
|
|
802
|
+
});
|
|
803
|
+
return _el$;
|
|
804
|
+
})();
|
|
805
|
+
}
|
|
806
|
+
const tui = async (api, options) => {
|
|
807
|
+
if (options?.enabled === false) return;
|
|
808
|
+
api.route.register([{
|
|
809
|
+
name: route,
|
|
810
|
+
render: ({
|
|
811
|
+
params
|
|
812
|
+
}) => _$createComponent(View, {
|
|
813
|
+
api: api,
|
|
814
|
+
get back() {
|
|
815
|
+
return parseBackTarget(params);
|
|
816
|
+
}
|
|
817
|
+
})
|
|
818
|
+
}]);
|
|
819
|
+
api.command.register(() => [{
|
|
820
|
+
title: "Token Usage Chart",
|
|
821
|
+
value: "token.usage.chart",
|
|
822
|
+
category: "Plugin",
|
|
823
|
+
slash: {
|
|
824
|
+
name: "token-chart"
|
|
825
|
+
},
|
|
826
|
+
onSelect: () => {
|
|
827
|
+
const current = api.route.current;
|
|
828
|
+
const back = current.name === "session" && current.params && typeof current.params.sessionID === "string" && current.params.sessionID.length > 0 ? {
|
|
829
|
+
name: "session",
|
|
830
|
+
params: {
|
|
831
|
+
sessionID: current.params.sessionID
|
|
832
|
+
}
|
|
833
|
+
} : {
|
|
834
|
+
name: "home"
|
|
835
|
+
};
|
|
836
|
+
api.route.navigate(route, {
|
|
837
|
+
back
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
}]);
|
|
841
|
+
};
|
|
842
|
+
const plugin = {
|
|
843
|
+
id,
|
|
844
|
+
tui
|
|
845
|
+
};
|
|
846
|
+
export default plugin;
|