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