@thiagos1lva/opencode-token-usage-chart 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # opencode-token-usage-chart
2
+
3
+ OpenCode TUI plugin that adds a token usage chart screen.
4
+
5
+ ## Install from npm
6
+
7
+ Package name:
8
+
9
+ `@thiagos1lva/opencode-token-usage-chart`
10
+
11
+ ### Global install (all projects)
12
+
13
+ Add to `~/.config/opencode/opencode.json`:
14
+
15
+ ```json
16
+ {
17
+ "$schema": "https://opencode.ai/config.json",
18
+ "plugin": [
19
+ "@thiagos1lva/opencode-token-usage-chart"
20
+ ]
21
+ }
22
+ ```
23
+
24
+ ### Local install (single repo)
25
+
26
+ Add to `<repo>/opencode.json`:
27
+
28
+ ```json
29
+ {
30
+ "$schema": "https://opencode.ai/config.json",
31
+ "plugin": [
32
+ "@thiagos1lva/opencode-token-usage-chart"
33
+ ]
34
+ }
35
+ ```
36
+
37
+ OpenCode installs npm plugins automatically at startup.
38
+
39
+ ## Use
40
+
41
+ - Run slash command `/token-chart`.
42
+ - Or open command palette and run `token.usage.chart`.
43
+
44
+ ## Local development
45
+
46
+ This repo also works as a local plugin via `tui.json`:
47
+
48
+ ```json
49
+ {
50
+ "$schema": "https://opencode.ai/tui.json",
51
+ "plugin": [
52
+ [
53
+ "./plugins/tui-token-usage.tsx",
54
+ {
55
+ "enabled": true
56
+ }
57
+ ]
58
+ ]
59
+ }
60
+ ```
61
+
62
+ ## Publish checklist
63
+
64
+ ```bash
65
+ npm login
66
+ npm pack --dry-run
67
+ npm publish --access public
68
+ ```
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./plugins/tui-token-usage.tsx"
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@thiagos1lva/opencode-token-usage-chart",
3
+ "version": "0.2.0",
4
+ "description": "OpenCode TUI plugin for token usage charts",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "plugins/tui-token-usage.tsx",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "tui",
20
+ "tokens"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/thiagos1lva/opencode-token-usage-chart.git"
25
+ },
26
+ "homepage": "https://github.com/thiagos1lva/opencode-token-usage-chart#readme",
27
+ "bugs": {
28
+ "url": "https://github.com/thiagos1lva/opencode-token-usage-chart/issues"
29
+ },
30
+ "author": "Thiago Silva",
31
+ "license": "MIT",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
@@ -0,0 +1,861 @@
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