@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.
@@ -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