opencode-tokenwatch 0.4.0 → 0.5.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/dist/tui.js ADDED
@@ -0,0 +1,3611 @@
1
+ // src/tui.tsx
2
+ import { createComponent as _$createComponent3 } from "@opentui/solid";
3
+ import { createSignal as createSignal2, createEffect as createEffect2, onCleanup as onCleanup2 } from "solid-js";
4
+
5
+ // src/commands.tsx
6
+ import { createComponent as _$createComponent } from "@opentui/solid";
7
+
8
+ // src/queries.ts
9
+ import { exec } from "node:child_process";
10
+ import { dirname, join } from "node:path";
11
+ function buildEnvWithOpencodePath() {
12
+ const nodeDir = dirname(process.execPath);
13
+ const candidateDirs = [
14
+ // nvm on Windows: opencode-ai 包的 bin 目录
15
+ join(nodeDir, "node_modules", "opencode-ai", "node_modules", "opencode-windows-x64", "bin"),
16
+ // npm global install: opencode 直接在 node_modules/.bin
17
+ join(nodeDir, "node_modules", ".bin"),
18
+ // 也加入 node.exe 所在目录本身(nvm 可能在此放 shim)
19
+ nodeDir
20
+ ];
21
+ const pathSep = process.platform === "win32" ? ";" : ":";
22
+ const extraPath = candidateDirs.join(pathSep);
23
+ return {
24
+ ...process.env,
25
+ PATH: `${extraPath}${pathSep}${process.env.PATH ?? ""}`
26
+ };
27
+ }
28
+ var OPENCODE_ENV = buildEnvWithOpencodePath();
29
+ function execAsync(cmd) {
30
+ return new Promise((resolve, reject) => {
31
+ exec(cmd, { windowsHide: true, timeout: 3e4, env: OPENCODE_ENV }, (error, stdout, stderr) => {
32
+ if (error) reject(Object.assign(error, { stderr }));
33
+ else resolve({ stdout, stderr });
34
+ });
35
+ });
36
+ }
37
+ async function queryDb(sql) {
38
+ const flatSql = sql.replace(/\s+/g, " ").trim();
39
+ const { stdout, stderr } = await execAsync(`opencode db ${JSON.stringify(flatSql)} --format json`);
40
+ if (stderr) throw new Error(stderr.trim());
41
+ const parsed = JSON.parse(stdout.trim());
42
+ return Array.isArray(parsed) ? parsed : parsed.data ?? [];
43
+ }
44
+ function escapeSql(value) {
45
+ return value.replace(/'/g, "''");
46
+ }
47
+ function isValidDate(s) {
48
+ return /^\d{4}-\d{2}-\d{2}$/.test(s);
49
+ }
50
+ function messageWhere(filters) {
51
+ const where = [
52
+ "json_extract(m.data, '$.role') = 'assistant'",
53
+ "coalesce(json_extract(m.data, '$.tokens.total'), 0) > 0"
54
+ ];
55
+ if (filters.sessionId) where.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
56
+ if (filters.provider) where.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
57
+ if (filters.model) where.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
58
+ if (filters.startDate && isValidDate(filters.startDate)) {
59
+ where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
60
+ }
61
+ if (filters.endDate && isValidDate(filters.endDate)) {
62
+ where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
63
+ }
64
+ return where.join(" AND ");
65
+ }
66
+ function parseList(value) {
67
+ if (!value) return [];
68
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
69
+ }
70
+ function toSessionTokenData(row) {
71
+ const models = parseList(row?.models_used);
72
+ const providers = parseList(row?.providers_used);
73
+ return {
74
+ model: models.length === 1 ? models[0] : "",
75
+ provider: providers.length === 1 ? providers[0] : "",
76
+ modelsUsed: models,
77
+ totalTokens: row?.total_tokens ?? 0,
78
+ inputTokens: row?.input_tokens ?? 0,
79
+ outputTokens: row?.output_tokens ?? 0,
80
+ reasoningTokens: row?.reasoning_tokens ?? 0,
81
+ cacheRead: row?.cache_read ?? 0,
82
+ cacheWrite: row?.cache_write ?? 0,
83
+ totalCost: row?.total_cost ?? 0,
84
+ requestCount: row?.request_count ?? 0
85
+ };
86
+ }
87
+ function getPresetRange(preset) {
88
+ if (preset === "all") return {};
89
+ const end = /* @__PURE__ */ new Date();
90
+ const start = new Date(end);
91
+ if (preset === "7d") start.setDate(end.getDate() - 6);
92
+ if (preset === "30d") start.setDate(end.getDate() - 29);
93
+ if (preset === "month") start.setDate(1);
94
+ const format = (date) => {
95
+ const year = date.getFullYear();
96
+ const month = String(date.getMonth() + 1).padStart(2, "0");
97
+ const day = String(date.getDate()).padStart(2, "0");
98
+ return `${year}-${month}-${day}`;
99
+ };
100
+ return { startDate: format(start), endDate: format(end) };
101
+ }
102
+ async function getSummary(filters = {}) {
103
+ const sql = `
104
+ SELECT
105
+ group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
106
+ group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
107
+ count(*) as request_count,
108
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
109
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
110
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
111
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
112
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
113
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
114
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
115
+ FROM message m
116
+ WHERE ${messageWhere(filters)}
117
+ `.trim();
118
+ const rows = await queryDb(sql);
119
+ return toSessionTokenData(rows[0]);
120
+ }
121
+ async function getModelBreakdown(filters = {}) {
122
+ const sql = `
123
+ SELECT
124
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
125
+ coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
126
+ count(*) as requests,
127
+ count(distinct m.session_id) as sessions,
128
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
129
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
130
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
131
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
132
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
133
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
134
+ FROM message m
135
+ WHERE ${messageWhere(filters)}
136
+ GROUP BY provider, model
137
+ ORDER BY total_tokens DESC
138
+ `.trim();
139
+ const rows = await queryDb(sql);
140
+ return rows.map((row) => ({
141
+ provider: row.provider ?? "unknown",
142
+ model: row.model ?? "unknown",
143
+ requests: row.requests ?? 0,
144
+ sessions: row.sessions ?? 0,
145
+ totalTokens: row.total_tokens ?? 0,
146
+ inputTokens: row.input_tokens ?? 0,
147
+ outputTokens: row.output_tokens ?? 0,
148
+ reasoningTokens: row.reasoning_tokens ?? 0,
149
+ cacheRead: row.cache_read ?? 0,
150
+ totalCost: row.total_cost ?? 0
151
+ }));
152
+ }
153
+ async function getProviderBreakdown(filters = {}) {
154
+ const sql = `
155
+ SELECT
156
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
157
+ count(*) as requests,
158
+ count(distinct m.session_id) as sessions,
159
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
160
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
161
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
162
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
163
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
164
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
165
+ FROM message m
166
+ WHERE ${messageWhere(filters)}
167
+ GROUP BY provider
168
+ ORDER BY total_tokens DESC
169
+ `.trim();
170
+ const rows = await queryDb(sql);
171
+ return rows.map((row) => ({
172
+ provider: row.provider ?? "unknown",
173
+ requests: row.requests ?? 0,
174
+ sessions: row.sessions ?? 0,
175
+ totalTokens: row.total_tokens ?? 0,
176
+ inputTokens: row.input_tokens ?? 0,
177
+ outputTokens: row.output_tokens ?? 0,
178
+ reasoningTokens: row.reasoning_tokens ?? 0,
179
+ cacheRead: row.cache_read ?? 0,
180
+ totalCost: row.total_cost ?? 0
181
+ }));
182
+ }
183
+ async function getDailyBreakdown(filters = {}) {
184
+ const limit = filters.limit ?? 30;
185
+ const sql = `
186
+ SELECT
187
+ date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
188
+ count(*) as requests,
189
+ count(distinct m.session_id) as sessions,
190
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
191
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
192
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
193
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
194
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
195
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
196
+ FROM message m
197
+ WHERE ${messageWhere(filters)}
198
+ GROUP BY day
199
+ ORDER BY day DESC
200
+ LIMIT ${Math.max(1, limit)}
201
+ `.trim();
202
+ const rows = await queryDb(sql);
203
+ return rows.map((row) => ({
204
+ day: row.day ?? "",
205
+ requests: row.requests ?? 0,
206
+ sessions: row.sessions ?? 0,
207
+ totalTokens: row.total_tokens ?? 0,
208
+ inputTokens: row.input_tokens ?? 0,
209
+ outputTokens: row.output_tokens ?? 0,
210
+ reasoningTokens: row.reasoning_tokens ?? 0,
211
+ cacheRead: row.cache_read ?? 0,
212
+ totalCost: row.total_cost ?? 0
213
+ }));
214
+ }
215
+ async function getSessionBreakdown(filters = {}) {
216
+ const limit = filters.limit ?? 15;
217
+ const sql = `
218
+ SELECT
219
+ s.id as session_id,
220
+ s.title as title,
221
+ coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
222
+ coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
223
+ count(*) as requests,
224
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
225
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
226
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
227
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
228
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
229
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
230
+ date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
231
+ FROM message m
232
+ JOIN session s ON s.id = m.session_id
233
+ WHERE ${messageWhere(filters)}
234
+ GROUP BY s.id, s.title, provider, model
235
+ ORDER BY max(m.time_created) DESC
236
+ LIMIT ${Math.max(1, limit)}
237
+ `.trim();
238
+ const rows = await queryDb(sql);
239
+ return rows.map((row) => ({
240
+ sessionId: row.session_id ?? "",
241
+ title: row.title ?? "(untitled)",
242
+ provider: row.provider ?? "unknown",
243
+ model: row.model ?? "unknown",
244
+ requests: row.requests ?? 0,
245
+ totalTokens: row.total_tokens ?? 0,
246
+ inputTokens: row.input_tokens ?? 0,
247
+ outputTokens: row.output_tokens ?? 0,
248
+ reasoningTokens: row.reasoning_tokens ?? 0,
249
+ cacheRead: row.cache_read ?? 0,
250
+ totalCost: row.total_cost ?? 0,
251
+ day: row.day ?? ""
252
+ }));
253
+ }
254
+ async function getErrorStats(filters = {}) {
255
+ const baseConds = [
256
+ "json_extract(m.data, '$.role') = 'assistant'"
257
+ ];
258
+ if (filters.sessionId) baseConds.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
259
+ if (filters.provider) baseConds.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
260
+ if (filters.model) baseConds.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
261
+ if (filters.startDate && isValidDate(filters.startDate)) {
262
+ baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
263
+ }
264
+ if (filters.endDate && isValidDate(filters.endDate)) {
265
+ baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
266
+ }
267
+ const baseWhere = baseConds.join(" AND ");
268
+ const sql = `
269
+ SELECT
270
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
271
+ coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
272
+ count(*) as total,
273
+ sum(CASE WHEN coalesce(json_extract(m.data, '$.tokens.total'), 0) = 0 THEN 1 ELSE 0 END) as failed
274
+ FROM message m
275
+ WHERE ${baseWhere}
276
+ GROUP BY provider, model
277
+ ORDER BY failed DESC
278
+ `.trim();
279
+ try {
280
+ const rows = await queryDb(sql);
281
+ let successCount = 0, failedCount = 0;
282
+ const byModel = rows.map((r) => {
283
+ const total = r.total ?? 0;
284
+ const failed = r.failed ?? 0;
285
+ const success = total - failed;
286
+ successCount += success;
287
+ failedCount += failed;
288
+ return { provider: r.provider ?? "unknown", model: r.model ?? "unknown", failed, total };
289
+ });
290
+ const errorRate = successCount + failedCount > 0 ? failedCount / (successCount + failedCount) : 0;
291
+ return { successCount, failedCount, errorRate, byModel };
292
+ } catch {
293
+ return { successCount: 0, failedCount: 0, errorRate: 0, byModel: [] };
294
+ }
295
+ }
296
+ async function getUsageReport(filters = {}) {
297
+ const [summary, models, providers, daily, sessions, errors] = await Promise.all([
298
+ getSummary(filters),
299
+ getModelBreakdown(filters),
300
+ getProviderBreakdown(filters),
301
+ getDailyBreakdown(filters),
302
+ getSessionBreakdown(filters),
303
+ getErrorStats(filters)
304
+ ]);
305
+ return { filters, summary, models, providers, daily, sessions, errors };
306
+ }
307
+
308
+ // src/formatter.ts
309
+ function formatTokens(n) {
310
+ if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B`;
311
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
312
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
313
+ return String(n);
314
+ }
315
+ function formatCost(n) {
316
+ if (n === 0) return "$0.00";
317
+ if (n < 0.01) return `$${n.toFixed(4)}`;
318
+ return `$${n.toFixed(2)}`;
319
+ }
320
+ function formatDuration(ms) {
321
+ if (ms === null) return "\u2014";
322
+ if (ms < 1e3) return `${ms.toFixed(0)}ms`;
323
+ if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
324
+ const m = Math.floor(ms / 6e4);
325
+ const s = Math.floor(ms % 6e4 / 1e3);
326
+ return `${m}m ${s}s`;
327
+ }
328
+ function formatFilters(filters) {
329
+ const parts = [];
330
+ if (filters.sessionId) parts.push(`session=${filters.sessionId}`);
331
+ if (filters.provider) parts.push(`provider=${filters.provider}`);
332
+ if (filters.model) parts.push(`model=${filters.model}`);
333
+ if (filters.startDate || filters.endDate) {
334
+ parts.push(`date=${filters.startDate ?? "..."}..${filters.endDate ?? "..."}`);
335
+ }
336
+ return parts.length ? parts.join(" | ") : "scope=all local sessions";
337
+ }
338
+ function getVisualWidth(str) {
339
+ let width = 0;
340
+ for (let i = 0; i < str.length; i++) {
341
+ width += str.charCodeAt(i) > 255 ? 2 : 1;
342
+ }
343
+ return width;
344
+ }
345
+ function truncateByWidth(str, maxWidth) {
346
+ if (getVisualWidth(str) <= maxWidth) return str;
347
+ let width = 0;
348
+ let res = "";
349
+ for (let i = 0; i < str.length; i++) {
350
+ const charWidth = str.charCodeAt(i) > 255 ? 2 : 1;
351
+ if (width + charWidth > maxWidth - 3) {
352
+ return res + "...";
353
+ }
354
+ width += charWidth;
355
+ res += str[i];
356
+ }
357
+ return res;
358
+ }
359
+ function table(columns, rows, totalRow) {
360
+ const widths = columns.map((col, i) => {
361
+ const rowWidths = rows.map((row) => getVisualWidth(row[i] ?? ""));
362
+ const maxRow = rowWidths.length ? Math.max(...rowWidths) : 0;
363
+ const totalWidth = totalRow ? getVisualWidth(totalRow[i] ?? "") : 0;
364
+ return Math.max(getVisualWidth(col.label), maxRow, totalWidth);
365
+ });
366
+ const renderRow = (cells) => "\u2551" + cells.map((cell, i) => {
367
+ const value = cell ?? "";
368
+ const vWidth = getVisualWidth(value);
369
+ const padding = " ".repeat(widths[i] - vWidth);
370
+ const content = columns[i].align === "right" ? padding + value : value + padding;
371
+ return ` ${content} \u2551`;
372
+ }).join("");
373
+ const sep = (left, mid, right, fill) => left + widths.map((w) => fill.repeat(w + 2)).join(mid) + right;
374
+ const lines = [
375
+ sep("\u2554", "\u2566", "\u2557", "\u2550"),
376
+ renderRow(columns.map((col) => col.label)),
377
+ sep("\u2560", "\u256C", "\u2563", "\u2550"),
378
+ ...rows.map(renderRow)
379
+ ];
380
+ if (totalRow) {
381
+ lines.push(sep("\u2560", "\u256C", "\u2563", "\u2550"));
382
+ lines.push(renderRow(totalRow));
383
+ }
384
+ lines.push(sep("\u255A", "\u2569", "\u255D", "\u2550"));
385
+ return lines.join("\n");
386
+ }
387
+ function formatSessionSummary(data, title = "Current Session") {
388
+ const modelLabel = data.modelsUsed.length > 1 ? `${data.modelsUsed.length} models` : data.model || "(unknown)";
389
+ return [
390
+ `\u2550\u2550\u2550 ${title} \u2550\u2550\u2550`,
391
+ `Models: ${modelLabel}`,
392
+ `Provider: ${data.provider || "(mixed)"}`,
393
+ `Req: ${data.requestCount}`,
394
+ `Total Tokens: ${formatTokens(data.totalTokens)}`,
395
+ ` Input: ${formatTokens(data.inputTokens)}`,
396
+ ` Output: ${formatTokens(data.outputTokens)}`,
397
+ ` Reasoning: ${formatTokens(data.reasoningTokens)}`,
398
+ ` C.Read: ${formatTokens(data.cacheRead)}`,
399
+ ` C.Write: ${formatTokens(data.cacheWrite)}`,
400
+ ` Cost: ${formatCost(data.totalCost)}`
401
+ ].join("\n");
402
+ }
403
+ function formatModelBreakdown(items) {
404
+ if (items.length === 0) return "\u2550\u2550\u2550 Model Breakdown \u2550\u2550\u2550\n(no data)";
405
+ const total = items.reduce((acc, item) => ({
406
+ requests: acc.requests + item.requests,
407
+ totalTokens: acc.totalTokens + item.totalTokens,
408
+ inputTokens: acc.inputTokens + item.inputTokens,
409
+ outputTokens: acc.outputTokens + item.outputTokens,
410
+ cacheRead: acc.cacheRead + item.cacheRead
411
+ }), {
412
+ requests: 0,
413
+ totalTokens: 0,
414
+ inputTokens: 0,
415
+ outputTokens: 0,
416
+ cacheRead: 0
417
+ });
418
+ const rows = items.map((item) => [
419
+ item.provider || "-",
420
+ truncateByWidth(item.model, 20),
421
+ String(item.requests),
422
+ formatTokens(item.totalTokens),
423
+ formatTokens(item.inputTokens),
424
+ formatTokens(item.outputTokens),
425
+ formatTokens(item.cacheRead)
426
+ ]);
427
+ return [
428
+ "\u2550\u2550\u2550 Model Breakdown \u2550\u2550\u2550",
429
+ table([
430
+ { label: "Provider", align: "left" },
431
+ { label: "Model", align: "left" },
432
+ { label: "Req", align: "right" },
433
+ { label: "Total", align: "right" },
434
+ { label: "In", align: "right" },
435
+ { label: "Out", align: "right" },
436
+ { label: "Cache", align: "right" }
437
+ ], rows, [
438
+ "TOTAL",
439
+ "",
440
+ String(total.requests),
441
+ formatTokens(total.totalTokens),
442
+ formatTokens(total.inputTokens),
443
+ formatTokens(total.outputTokens),
444
+ formatTokens(total.cacheRead)
445
+ ])
446
+ ].join("\n");
447
+ }
448
+ function formatProviderBreakdown(items) {
449
+ if (items.length === 0) return "\u2550\u2550\u2550 Provider Breakdown \u2550\u2550\u2550\n(no data)";
450
+ const total = items.reduce((acc, item) => ({
451
+ requests: acc.requests + item.requests,
452
+ totalTokens: acc.totalTokens + item.totalTokens,
453
+ inputTokens: acc.inputTokens + item.inputTokens,
454
+ outputTokens: acc.outputTokens + item.outputTokens,
455
+ cacheRead: acc.cacheRead + item.cacheRead
456
+ }), {
457
+ requests: 0,
458
+ totalTokens: 0,
459
+ inputTokens: 0,
460
+ outputTokens: 0,
461
+ cacheRead: 0
462
+ });
463
+ const rows = items.map((item) => [
464
+ item.provider || "-",
465
+ String(item.requests),
466
+ formatTokens(item.totalTokens),
467
+ formatTokens(item.inputTokens),
468
+ formatTokens(item.outputTokens),
469
+ formatTokens(item.cacheRead)
470
+ ]);
471
+ return [
472
+ "\u2550\u2550\u2550 Provider Breakdown \u2550\u2550\u2550",
473
+ table([
474
+ { label: "Provider", align: "left" },
475
+ { label: "Req", align: "right" },
476
+ { label: "Total", align: "right" },
477
+ { label: "In", align: "right" },
478
+ { label: "Out", align: "right" },
479
+ { label: "Cache", align: "right" }
480
+ ], rows, [
481
+ "TOTAL",
482
+ String(total.requests),
483
+ formatTokens(total.totalTokens),
484
+ formatTokens(total.inputTokens),
485
+ formatTokens(total.outputTokens),
486
+ formatTokens(total.cacheRead)
487
+ ])
488
+ ].join("\n");
489
+ }
490
+ function formatDailyBreakdown(items) {
491
+ if (items.length === 0) return "\u2550\u2550\u2550 Daily Breakdown \u2550\u2550\u2550\n(no data)";
492
+ const total = items.reduce((acc, item) => ({
493
+ requests: acc.requests + item.requests,
494
+ totalTokens: acc.totalTokens + item.totalTokens,
495
+ inputTokens: acc.inputTokens + item.inputTokens,
496
+ outputTokens: acc.outputTokens + item.outputTokens,
497
+ cacheRead: acc.cacheRead + item.cacheRead
498
+ }), {
499
+ requests: 0,
500
+ totalTokens: 0,
501
+ inputTokens: 0,
502
+ outputTokens: 0,
503
+ cacheRead: 0
504
+ });
505
+ const rows = items.map((item) => [
506
+ item.day,
507
+ String(item.requests),
508
+ formatTokens(item.totalTokens),
509
+ formatTokens(item.inputTokens),
510
+ formatTokens(item.outputTokens),
511
+ formatTokens(item.cacheRead)
512
+ ]);
513
+ return [
514
+ "\u2550\u2550\u2550 Daily Breakdown \u2550\u2550\u2550",
515
+ table([
516
+ { label: "Day", align: "left" },
517
+ { label: "Req", align: "right" },
518
+ { label: "Total", align: "right" },
519
+ { label: "In", align: "right" },
520
+ { label: "Out", align: "right" },
521
+ { label: "Cache", align: "right" }
522
+ ], rows, [
523
+ "TOTAL",
524
+ String(total.requests),
525
+ formatTokens(total.totalTokens),
526
+ formatTokens(total.inputTokens),
527
+ formatTokens(total.outputTokens),
528
+ formatTokens(total.cacheRead)
529
+ ])
530
+ ].join("\n");
531
+ }
532
+ function formatSessionBreakdown(items) {
533
+ if (items.length === 0) return "\u2550\u2550\u2550 Session Breakdown \u2550\u2550\u2550\n(no data)";
534
+ const rows = items.map((item) => {
535
+ const title = truncateByWidth(item.title, 40);
536
+ return [
537
+ item.day,
538
+ item.provider || "-",
539
+ truncateByWidth(item.model, 20),
540
+ String(item.requests),
541
+ formatTokens(item.totalTokens),
542
+ formatTokens(item.cacheRead),
543
+ title
544
+ ];
545
+ });
546
+ return [
547
+ "\u2550\u2550\u2550 Session Breakdown \u2550\u2550\u2550",
548
+ table([
549
+ { label: "Day", align: "left" },
550
+ { label: "Provider", align: "left" },
551
+ { label: "Model", align: "left" },
552
+ { label: "Req", align: "right" },
553
+ { label: "Total", align: "right" },
554
+ { label: "Cache", align: "right" },
555
+ { label: "Title", align: "left" }
556
+ ], rows)
557
+ ].join("\n");
558
+ }
559
+ function formatUsageReport(report) {
560
+ return [
561
+ `Filters: ${formatFilters(report.filters)}`,
562
+ "",
563
+ formatSessionSummary(report.summary, "Usage Summary"),
564
+ "",
565
+ formatModelBreakdown(report.models),
566
+ "",
567
+ formatProviderBreakdown(report.providers),
568
+ "",
569
+ formatDailyBreakdown(report.daily),
570
+ "",
571
+ formatSessionBreakdown(report.sessions)
572
+ ].join("\n");
573
+ }
574
+
575
+ // src/generate-usage-html.ts
576
+ function fmtTokens(n) {
577
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + "B";
578
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
579
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
580
+ return String(n);
581
+ }
582
+ function fmtCost(n) {
583
+ if (n === 0) return "$0.00";
584
+ if (n < 0.01) return "$" + n.toFixed(6);
585
+ return "$" + n.toFixed(2);
586
+ }
587
+ function fmtPercent(n) {
588
+ return (n * 100).toFixed(1) + "%";
589
+ }
590
+ function cacheHitRate(input, cacheRead) {
591
+ if (input + cacheRead === 0) return 0;
592
+ return cacheRead / (input + cacheRead);
593
+ }
594
+ function sortModelsByUsage(models) {
595
+ return [...models].filter((m) => m.totalTokens > 0).sort((a, b) => b.totalTokens - a.totalTokens);
596
+ }
597
+ function renderMeta(data) {
598
+ const m = data.meta;
599
+ return `TokenWatch Usage Report \xB7 ${m.dateRange.start} \u2192 ${m.dateRange.end} \xB7 generated ${m.generatedAt}`;
600
+ }
601
+ function renderKpiCards(data) {
602
+ const s = data.summary;
603
+ const hitRate = cacheHitRate(s.inputTokens, s.cacheRead);
604
+ const hitRatePct = fmtPercent(hitRate);
605
+ let tpsSum = 0, tpsReqs = 0;
606
+ for (const p of data.perfSummary) {
607
+ if (p.avgTPS != null && p.avgTPS > 0) {
608
+ tpsSum += p.avgTPS * p.requestCount;
609
+ tpsReqs += p.requestCount;
610
+ }
611
+ }
612
+ const avgTpsRaw = tpsReqs > 0 ? tpsSum / tpsReqs : 0;
613
+ const avgTps = tpsReqs > 0 ? avgTpsRaw.toFixed(1) : "\u2014";
614
+ const isHighCache = hitRate >= 0.5;
615
+ const errors = data.errors;
616
+ const errorRatePct = errors ? (errors.errorRate * 100).toFixed(1) + "%" : "\u2014";
617
+ const errorColor = errors && errors.errorRate >= 0.05 ? "var(--output)" : errors && errors.errorRate > 0 ? "var(--tps)" : "var(--cache)";
618
+ return `
619
+ <div class="kpi-row">
620
+ <div class="kpi-card">
621
+ <div class="kpi-label">Total Tokens</div>
622
+ <div class="kpi-value">${fmtTokens(s.totalTokens)}</div>
623
+ </div>
624
+ <div class="kpi-card${isHighCache ? " kpi-glow" : ""}">
625
+ <div class="kpi-label">Cache Hit Rate</div>
626
+ <div class="kpi-value" style="color:var(--cache)">${hitRatePct}</div>
627
+ </div>
628
+ <div class="kpi-card">
629
+ <div class="kpi-label">Avg TPS</div>
630
+ <div class="kpi-value" style="color:var(--tps)">${avgTps}</div>
631
+ </div>
632
+ <div class="kpi-card">
633
+ <div class="kpi-label">Requests</div>
634
+ <div class="kpi-value">${s.requestCount}</div>
635
+ </div>
636
+ <div class="kpi-card">
637
+ <div class="kpi-label">Total Cost</div>
638
+ <div class="kpi-value" style="color:var(--tps)">${fmtCost(s.totalCost)}</div>
639
+ </div>
640
+ <div class="kpi-card">
641
+ <div class="kpi-label">Error Rate</div>
642
+ <div class="kpi-value" style="color:${errorColor}">${errorRatePct}</div>
643
+ </div>
644
+ </div>`;
645
+ }
646
+ function renderModelChartInit(data) {
647
+ const models = sortModelsByUsage(data.models).filter((m) => m.totalTokens >= 1e6);
648
+ const names = models.map((m) => m.model);
649
+ const inputData = models.map((m) => m.inputTokens);
650
+ const outputData = models.map((m) => m.outputTokens);
651
+ const cacheData = models.map((m) => m.cacheRead);
652
+ const tpsData = models.map((m) => {
653
+ const perf = data.perfSummary.find((p) => p.model === `${m.provider}/${m.model}`);
654
+ return perf?.avgTPS ?? null;
655
+ });
656
+ return `var modelNames = ${JSON.stringify(names)};
657
+ var modelInput = ${JSON.stringify(inputData)};
658
+ var modelOutput = ${JSON.stringify(outputData)};
659
+ var modelCache = ${JSON.stringify(cacheData)};
660
+ var modelTps = ${JSON.stringify(tpsData)};
661
+
662
+ function initModelChart() {
663
+ var el = document.getElementById('model-chart');
664
+ if (!el) return;
665
+ var chart = echarts.init(el);
666
+ window.modelChart = chart;
667
+ renderModelChart(chart);
668
+ return chart;
669
+ }
670
+
671
+ function renderModelChart(chart) {
672
+ var totals = modelInput.map(function(v, i) { return v + modelOutput[i] + modelCache[i]; });
673
+ var option = {
674
+ tooltip: {
675
+ trigger: 'axis',
676
+ axisPointer: { type: 'shadow' },
677
+ formatter: function(params) {
678
+ var html = '<b>' + params[0].axisValue + '</b><br/>';
679
+ var total = 0;
680
+ params.forEach(function(p) {
681
+ if (p.seriesName !== 'TPS') {
682
+ html += p.marker + ' ' + p.seriesName + ': ' + fmt(p.value) + '<br/>';
683
+ total += p.value;
684
+ }
685
+ });
686
+ html += 'Total: ' + fmt(total) + '<br/>';
687
+ var tpsParam = params.find(function(p) { return p.seriesName === 'TPS'; });
688
+ if (tpsParam && tpsParam.value != null) {
689
+ html += tpsParam.marker + ' TPS: ' + tpsParam.value.toFixed(1) + '<br/>';
690
+ }
691
+ return html;
692
+ }
693
+ },
694
+ legend: {
695
+ data: ['Input', 'Output', 'Cache', 'TPS'],
696
+ textStyle: { color: '#B0B0C0' },
697
+ top: 5
698
+ },
699
+ grid: { left: 60, right: 60, bottom: 100, top: 50 },
700
+ xAxis: {
701
+ type: 'category',
702
+ data: modelNames,
703
+ axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
704
+ axisLine: { lineStyle: { color: '#2A2A35' } }
705
+ },
706
+ yAxis: [
707
+ {
708
+ type: 'value',
709
+ name: 'Tokens',
710
+ nameTextStyle: { color: '#B0B0C0' },
711
+ axisLabel: {
712
+ color: '#B0B0C0',
713
+ formatter: fmt
714
+ },
715
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
716
+ },
717
+ {
718
+ type: 'value',
719
+ name: 'TPS',
720
+ nameTextStyle: { color: '#FFB800' },
721
+ axisLabel: { color: '#FFB800', formatter: function(v) { return v.toFixed(1); } },
722
+ splitLine: { show: false }
723
+ }
724
+ ],
725
+ series: [
726
+ {
727
+ name: 'Input',
728
+ type: 'bar',
729
+ stack: 'tokens',
730
+ data: modelInput,
731
+ itemStyle: { color: '#00D1FF' },
732
+ barMaxWidth: 40
733
+ },
734
+ {
735
+ name: 'Cache',
736
+ type: 'bar',
737
+ stack: 'tokens',
738
+ data: modelCache,
739
+ itemStyle: { color: '#00F593' },
740
+ barMaxWidth: 40,
741
+ label: {
742
+ show: true,
743
+ position: 'inside',
744
+ formatter: function(p) {
745
+ var cache = modelCache[p.dataIndex];
746
+ var input = modelInput[p.dataIndex];
747
+ if (cache === 0) return '';
748
+ return (cache / (input + cache) * 100).toFixed(0) + '%';
749
+ },
750
+ color: '#fff', fontSize: 10, fontWeight: 'bold'
751
+ }
752
+ },
753
+ {
754
+ name: 'Output',
755
+ type: 'bar',
756
+ stack: 'tokens',
757
+ data: modelOutput,
758
+ itemStyle: { color: '#B545FF' },
759
+ barMaxWidth: 40,
760
+ label: {
761
+ show: true,
762
+ position: 'top',
763
+ formatter: function(p) {
764
+ var total = modelInput[p.dataIndex] + modelOutput[p.dataIndex] + modelCache[p.dataIndex];
765
+ return total > 0 ? fmt(total) : '';
766
+ },
767
+ color: '#fff', fontSize: 10, fontWeight: 'bold'
768
+ }
769
+ },
770
+ {
771
+ name: 'TPS',
772
+ type: 'scatter',
773
+ yAxisIndex: 1,
774
+ data: modelTps,
775
+ symbol: 'diamond',
776
+ symbolSize: function(val) { return val != null && val > 0 ? 13 : 0; },
777
+ itemStyle: { color: '#FFB800' },
778
+ label: {
779
+ show: true,
780
+ position: 'right',
781
+ formatter: function(p) { return p.value != null && p.value > 0 ? p.value.toFixed(1) : ''; },
782
+ color: '#FFB800', fontSize: 10
783
+ }
784
+ }
785
+ ]
786
+ };
787
+ chart.setOption(option);
788
+ chart.resize();
789
+ }`;
790
+ }
791
+ function renderScatterChartInit(data) {
792
+ const validPerf = data.perfSummary.filter(
793
+ (p) => p.requestCount > 0 && p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite > 0
794
+ );
795
+ if (validPerf.length === 0) return "";
796
+ const sorted = [...validPerf].sort((a, b) => {
797
+ if (a.avgTPS == null && b.avgTPS == null) return 0;
798
+ if (a.avgTPS == null) return 1;
799
+ if (b.avgTPS == null) return -1;
800
+ return b.avgTPS - a.avgTPS;
801
+ });
802
+ const names = sorted.map((p) => p.model);
803
+ const tpsValues = sorted.map((p) => p.avgTPS ?? 0);
804
+ const ttftValues = sorted.map((p) => p.avgTTFT ?? 0);
805
+ const costValues = sorted.map((p) => {
806
+ const billable = p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite;
807
+ return billable > 0 ? p.totalCost / billable * 1e3 : 0;
808
+ });
809
+ const hitRates = sorted.map((p) => p.cacheHitRate ?? 0);
810
+ const reqCounts = sorted.map((p) => p.requestCount);
811
+ return `
812
+ var effNames = ${JSON.stringify(names)};
813
+ var effTps = ${JSON.stringify(tpsValues)};
814
+ var effTtft = ${JSON.stringify(ttftValues)};
815
+ var effCost = ${JSON.stringify(costValues)};
816
+ var effHit = ${JSON.stringify(hitRates)};
817
+ var effReq = ${JSON.stringify(reqCounts)};
818
+
819
+ function initScatterChart() {
820
+ var el = document.getElementById('scatter-chart');
821
+ if (!el) return;
822
+ var chart = echarts.init(el);
823
+ window.scatterChart = chart;
824
+
825
+ // TPS \u8D8A\u9AD8\u8D8A\u7EFF\uFF0C\u8D8A\u4F4E\u8D8A\u7D2B\uFF0C\u65E0\u6570\u636E\u4E3A\u7070
826
+ var maxTps = Math.max.apply(null, effTps.filter(function(v){ return v > 0; })) || 1;
827
+ var barColors = effTps.map(function(v) {
828
+ if (v <= 0) return '#444455';
829
+ var r = v / maxTps;
830
+ if (r >= 0.8) return '#00F593';
831
+ if (r >= 0.5) return '#FFB800';
832
+ return '#B545FF';
833
+ });
834
+
835
+ var option = {
836
+ tooltip: {
837
+ trigger: 'axis',
838
+ axisPointer: { type: 'none' },
839
+ formatter: function(params) {
840
+ var i = params[0].dataIndex;
841
+ var tps = effTps[i] > 0 ? effTps[i].toFixed(1) + ' tok/s' : '\u2014';
842
+ var ttft = effTtft[i] > 0 ? effTtft[i].toFixed(0) + ' ms' : '\u2014';
843
+ var cost = effCost[i] > 0 ? '$' + effCost[i].toFixed(4) + '/1K' : '\u2014';
844
+ var hit = effHit[i] > 0 ? effHit[i].toFixed(1) + '%' : '\u2014';
845
+ return '<b>' + effNames[i] + '</b><br/>' +
846
+ '\u25B6 TPS: ' + tps + '<br/>' +
847
+ '\u23F1 TTFT: ' + ttft + '<br/>' +
848
+ '\u{1F4B0} Cost/1K: ' + cost + '<br/>' +
849
+ '\u{1F4BE} Cache Hit: ' + hit + '<br/>' +
850
+ 'Requests: ' + effReq[i];
851
+ }
852
+ },
853
+ grid: { left: 20, right: 280, bottom: 30, top: 20, containLabel: true },
854
+ xAxis: {
855
+ type: 'value',
856
+ name: 'Avg TPS (tokens / sec)',
857
+ nameTextStyle: { color: '#B0B0C0', fontSize: 11 },
858
+ axisLabel: { color: '#B0B0C0', formatter: function(v) { return v > 0 ? v.toFixed(0) : '0'; } },
859
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
860
+ },
861
+ yAxis: {
862
+ type: 'category',
863
+ data: effNames,
864
+ inverse: true,
865
+ axisLabel: {
866
+ color: '#E0E0F0',
867
+ fontSize: 11,
868
+ formatter: function(v) { return v.length > 50 ? v.slice(0, 48) + '\u2026' : v; }
869
+ },
870
+ axisLine: { show: false },
871
+ axisTick: { show: false }
872
+ },
873
+ series: [{
874
+ type: 'bar',
875
+ data: effTps.map(function(v, i) {
876
+ return { value: v > 0 ? v : 0.001, itemStyle: { color: barColors[i], borderRadius: [0, 4, 4, 0] } };
877
+ }),
878
+ barMaxWidth: 22,
879
+ label: {
880
+ show: true,
881
+ position: 'right',
882
+ color: '#E0E0F0',
883
+ fontSize: 10,
884
+ formatter: function(p) {
885
+ var i = p.dataIndex;
886
+ var parts = [effTps[i] > 0 ? effTps[i].toFixed(1) + ' t/s' : '\u2014'];
887
+ if (effTtft[i] > 0) parts.push('TTFT ' + effTtft[i].toFixed(0) + 'ms');
888
+ if (effCost[i] > 0) parts.push('$' + effCost[i].toFixed(4) + '/1K');
889
+ return parts.join(' ');
890
+ }
891
+ }
892
+ }]
893
+ };
894
+ chart.setOption(option);
895
+ chart.resize();
896
+ }
897
+ `;
898
+ }
899
+ function providerBorderColor(provider) {
900
+ const colors = { opencode: "#00F593", deepseek: "#00D1FF", nvidia: "#B545FF", modelscope: "#FFB800" };
901
+ return colors[provider] || "#2A2A35";
902
+ }
903
+ function renderProviderCards(data) {
904
+ const sorted = [...data.providers].sort((a, b) => b.totalTokens - a.totalTokens);
905
+ const top = sorted.slice(0, 10);
906
+ const remaining = sorted.length - 10;
907
+ const cards = top.map((p) => {
908
+ const modelCount = data.models.filter((m) => m.provider === p.provider).length;
909
+ const perfItems = data.perfSummary.filter((ps) => ps.providerID === p.provider);
910
+ let ttftSum = 0, ttftReqs = 0;
911
+ for (const x of perfItems) {
912
+ if (x.avgTTFT != null && x.avgTTFT > 0) {
913
+ ttftSum += x.avgTTFT * x.requestCount;
914
+ ttftReqs += x.requestCount;
915
+ }
916
+ }
917
+ const avgTtft = ttftReqs > 0 ? ttftSum / ttftReqs : null;
918
+ let tpsSum = 0, tpsReqs = 0;
919
+ for (const x of perfItems) {
920
+ if (x.avgTPS != null && x.avgTPS > 0) {
921
+ tpsSum += x.avgTPS * x.requestCount;
922
+ tpsReqs += x.requestCount;
923
+ }
924
+ }
925
+ const avgTps = tpsReqs > 0 ? tpsSum / tpsReqs : null;
926
+ return `
927
+ <div class="provider-card" style="border-color:${providerBorderColor(p.provider)}">
928
+ <div class="provider-name">${p.provider}</div>
929
+ <div class="provider-stat"><span class="stat-label">Tokens</span><span>${fmtTokens(p.totalTokens)}</span></div>
930
+ <div class="provider-stat"><span class="stat-label">Cost</span><span>${fmtCost(p.totalCost)}</span></div>
931
+ <div class="provider-stat"><span class="stat-label">Avg TTFT</span><span>${avgTtft != null ? avgTtft.toFixed(0) + "ms" : "\u2014"}</span></div>
932
+ <div class="provider-stat"><span class="stat-label">Avg TPS</span><span>${avgTps != null ? avgTps.toFixed(1) : "\u2014"}</span></div>
933
+ <div class="provider-stat"><span class="stat-label">Models</span><span>${modelCount}</span></div>
934
+ </div>`;
935
+ }).join("\n");
936
+ const moreHint = remaining > 0 ? `<div class="provider-more">+ ${remaining} more provider${remaining > 1 ? "s" : ""} not shown</div>` : "";
937
+ return cards + moreHint;
938
+ }
939
+ function renderModelAnalyticsSection(data) {
940
+ const usageRows = sortModelsByUsage(data.models).map((m) => {
941
+ const hitRate = cacheHitRate(m.inputTokens, m.cacheRead);
942
+ const hitColor = hitRate >= 0.85 ? "var(--cache)" : hitRate >= 0.7 ? "var(--tps)" : "var(--output)";
943
+ const perf = data.perfSummary.find((p) => p.model === `${m.provider}/${m.model}`);
944
+ const ttft = perf?.avgTTFT != null ? perf.avgTTFT.toFixed(0) + "ms" : "\u2014";
945
+ const p95ttft = perf?.p95TTFT != null ? perf.p95TTFT.toFixed(0) + "ms" : "\u2014";
946
+ const tps = perf?.avgTPS != null ? perf.avgTPS.toFixed(1) : "\u2014";
947
+ return `<tr>
948
+ <td>${m.model}</td>
949
+ <td>${m.provider}</td>
950
+ <td>${m.requests}</td>
951
+ <td>${fmtTokens(m.totalTokens)}</td>
952
+ <td>${fmtTokens(m.inputTokens)}</td>
953
+ <td>${fmtTokens(m.outputTokens)}</td>
954
+ <td>${fmtTokens(m.cacheRead)}</td>
955
+ <td style="color:${hitColor};font-weight:600">${fmtPercent(hitRate)}</td>
956
+ <td>${ttft}</td>
957
+ <td style="color:var(--tps);font-size:0.85em">${p95ttft}</td>
958
+ <td>${tps}</td>
959
+ <td>${fmtCost(m.totalCost)}</td>
960
+ </tr>`;
961
+ }).join("\n");
962
+ const validPerf = data.perfSummary.filter(
963
+ (p) => p.requestCount > 0 && p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite > 0
964
+ );
965
+ const fmtMs = (v) => v != null ? v.toFixed(0) + "ms" : "\u2014";
966
+ const perfRows = validPerf.map((p) => {
967
+ const hitColor = p.cacheHitRate != null && p.cacheHitRate >= 85 ? "var(--cache)" : p.cacheHitRate != null && p.cacheHitRate >= 70 ? "var(--tps)" : "var(--output)";
968
+ return `<tr>
969
+ <td>${p.model}</td>
970
+ <td>${p.requestCount}</td>
971
+ <td>${fmtMs(p.avgTTFT)}</td>
972
+ <td>${fmtMs(p.p50TTFT)}</td>
973
+ <td>${fmtMs(p.p95TTFT)}</td>
974
+ <td>${fmtMs(p.p99TTFT)}</td>
975
+ <td>${fmtMs(p.avgLatency)}</td>
976
+ <td>${fmtMs(p.p50Latency)}</td>
977
+ <td>${fmtMs(p.p95Latency)}</td>
978
+ <td>${fmtMs(p.p99Latency)}</td>
979
+ <td style="color:${hitColor};font-weight:600">${p.cacheHitRate != null ? p.cacheHitRate.toFixed(1) + "%" : "\u2014"}</td>
980
+ </tr>`;
981
+ }).join("\n");
982
+ const errors = data.errors;
983
+ const hasErrors = !!(errors && errors.failedCount > 0);
984
+ let errorTabBtn = "";
985
+ let errorTabContent = "";
986
+ if (hasErrors) {
987
+ const errorRatePct = (errors.errorRate * 100).toFixed(2) + "%";
988
+ const rateColor = errors.errorRate >= 0.05 ? "var(--output)" : "var(--tps)";
989
+ const cellColor = errors.errorRate >= 0.05 ? "var(--output)" : "var(--tps)";
990
+ const errorRows = errors.byModel.filter((m) => m.failed > 0).map((m) => {
991
+ const modelRate = m.total > 0 ? (m.failed / m.total * 100).toFixed(1) + "%" : "\u2014";
992
+ return `<tr>
993
+ <td>${m.provider}</td>
994
+ <td>${m.model}</td>
995
+ <td>${m.total}</td>
996
+ <td style="color:var(--output)">${m.failed}</td>
997
+ <td style="color:var(--tps)">${m.total - m.failed}</td>
998
+ <td style="color:${cellColor}">${modelRate}</td>
999
+ </tr>`;
1000
+ }).join("\n");
1001
+ errorTabBtn = `
1002
+ <button class="tab-btn" data-mtab="errors" onclick="switchModelTab('errors')">
1003
+ Failed Requests <span style="color:var(--output);margin-left:4px;font-size:0.85em">(${errors.failedCount})</span>
1004
+ </button>`;
1005
+ errorTabContent = `
1006
+ <div id="model-tab-errors" class="tab-content">
1007
+ <p style="font-size:12px;color:${rateColor};padding:8px 0 6px">
1008
+ Overall error rate: <strong>${errorRatePct}</strong> &mdash;
1009
+ ${errors.failedCount} failed / ${errors.successCount + errors.failedCount} total
1010
+ </p>
1011
+ <table id="errors-table" class="data-table">
1012
+ <thead><tr>
1013
+ <th>Provider</th><th>Model</th><th>Total</th>
1014
+ <th>Failed</th><th>Success</th><th>Error Rate</th>
1015
+ </tr></thead>
1016
+ <tbody>${errorRows}</tbody>
1017
+ </table>
1018
+ <div class="pagination-ctrl" id="errors-table-ctrl">
1019
+ <button class="page-btn" id="errors-table-prev">\u2190 Prev</button>
1020
+ <span class="page-info" id="errors-table-info"></span>
1021
+ <button class="page-btn" id="errors-table-next">Next \u2192</button>
1022
+ </div>
1023
+ </div>`;
1024
+ }
1025
+ return `
1026
+ <div class="section">
1027
+ <div class="section-title">Model Analytics</div>
1028
+ <div class="tab-bar">
1029
+ <button class="tab-btn active" data-mtab="usage" onclick="switchModelTab('usage')">Usage Breakdown</button>
1030
+ <button class="tab-btn" data-mtab="perf" onclick="switchModelTab('perf')">Latency Percentiles</button>
1031
+ ${errorTabBtn}
1032
+ </div>
1033
+
1034
+ <div id="model-tab-usage" class="tab-content active">
1035
+ <table id="usage-table" class="data-table">
1036
+ <thead><tr>
1037
+ <th>Model</th><th>Provider</th><th>Req</th><th>Total</th>
1038
+ <th>Input</th><th>Output</th><th>Cache</th><th>Hit Rate</th>
1039
+ <th>Avg TTFT</th><th>P95 TTFT</th><th>TPS</th><th>Cost</th>
1040
+ </tr></thead>
1041
+ <tbody>${usageRows}</tbody>
1042
+ </table>
1043
+ <div class="pagination-ctrl" id="usage-table-ctrl">
1044
+ <button class="page-btn" id="usage-table-prev">\u2190 Prev</button>
1045
+ <span class="page-info" id="usage-table-info"></span>
1046
+ <button class="page-btn" id="usage-table-next">Next \u2192</button>
1047
+ </div>
1048
+ </div>
1049
+
1050
+ <div id="model-tab-perf" class="tab-content">
1051
+ ${validPerf.length > 0 ? `
1052
+ <table id="perf-table" class="data-table">
1053
+ <thead><tr>
1054
+ <th>Model</th><th>Req</th>
1055
+ <th>Avg TTFT</th><th>P50 TTFT</th><th>P95 TTFT</th><th>P99 TTFT</th>
1056
+ <th>Avg E2E</th><th>P50 E2E</th><th>P95 E2E</th><th>P99 E2E</th>
1057
+ <th>Cache Hit</th>
1058
+ </tr></thead>
1059
+ <tbody>${perfRows}</tbody>
1060
+ </table>
1061
+ <div class="pagination-ctrl" id="perf-table-ctrl">
1062
+ <button class="page-btn" id="perf-table-prev">\u2190 Prev</button>
1063
+ <span class="page-info" id="perf-table-info"></span>
1064
+ <button class="page-btn" id="perf-table-next">Next \u2192</button>
1065
+ </div>` : '<div class="empty-state">No performance data available for this period.</div>'}
1066
+ </div>
1067
+
1068
+ ${errorTabContent}
1069
+ </div>`;
1070
+ }
1071
+ function renderDailyTrendInit(data) {
1072
+ const days = data.daily.slice().reverse().map((d) => d.day);
1073
+ const tokens = data.daily.slice().reverse().map((d) => d.totalTokens);
1074
+ const costs = data.daily.slice().reverse().map((d) => d.totalCost);
1075
+ return `
1076
+ var dailyDays = ${JSON.stringify(days)};
1077
+ var dailyTokens = ${JSON.stringify(tokens)};
1078
+ var dailyCosts = ${JSON.stringify(costs)};
1079
+
1080
+ function initDailyChart() {
1081
+ var el = document.getElementById('daily-chart');
1082
+ if (!el) return;
1083
+ var chart = echarts.init(el);
1084
+ window.dailyChart = chart;
1085
+ var option = {
1086
+ tooltip: {
1087
+ trigger: 'axis',
1088
+ formatter: function(params) {
1089
+ var html = '<b>' + params[0].axisValue + '</b><br/>';
1090
+ params.forEach(function(p) {
1091
+ html += p.marker + ' ' + p.seriesName + ': ' + (p.seriesName === 'Cost' ? '$' + p.value.toFixed(4) : fmt(p.value)) + '<br/>';
1092
+ });
1093
+ return html;
1094
+ }
1095
+ },
1096
+ legend: {
1097
+ data: ['Tokens', 'Cost'],
1098
+ textStyle: { color: '#B0B0C0' },
1099
+ top: 5
1100
+ },
1101
+ grid: { left: 60, right: 60, bottom: 80, top: 40 },
1102
+ xAxis: {
1103
+ type: 'category',
1104
+ data: dailyDays,
1105
+ axisLabel: { color: '#B0B0C0', rotate: 45, interval: 0, fontSize: 10 },
1106
+ axisLine: { lineStyle: { color: '#2A2A35' } }
1107
+ },
1108
+ yAxis: [
1109
+ {
1110
+ type: 'value',
1111
+ name: 'Tokens',
1112
+ nameTextStyle: { color: '#B0B0C0' },
1113
+ axisLabel: { color: '#B0B0C0', formatter: fmt },
1114
+ splitLine: { lineStyle: { color: '#2A2A35', type: 'dashed' } }
1115
+ },
1116
+ {
1117
+ type: 'value',
1118
+ name: 'Cost',
1119
+ nameTextStyle: { color: '#FFB800' },
1120
+ axisLabel: { color: '#FFB800', formatter: function(v) { return '$' + v.toFixed(4); } },
1121
+ splitLine: { show: false }
1122
+ }
1123
+ ],
1124
+ dataZoom: [{
1125
+ type: 'slider',
1126
+ bottom: 5,
1127
+ height: 20,
1128
+ borderColor: '#2A2A35',
1129
+ fillerColor: 'rgba(0,213,255,0.1)',
1130
+ handleStyle: { color: '#00D1FF' },
1131
+ textStyle: { color: '#B0B0C0' }
1132
+ }],
1133
+ series: [
1134
+ {
1135
+ name: 'Tokens',
1136
+ type: 'line',
1137
+ data: dailyTokens,
1138
+ smooth: true,
1139
+ symbol: 'none',
1140
+ lineStyle: { color: '#00D1FF', width: 2 },
1141
+ areaStyle: { color: 'rgba(0,209,255,0.15)' }
1142
+ },
1143
+ {
1144
+ name: 'Cost',
1145
+ type: 'line',
1146
+ yAxisIndex: 1,
1147
+ data: dailyCosts,
1148
+ smooth: true,
1149
+ symbol: 'none',
1150
+ lineStyle: { color: '#FFB800', width: 2 },
1151
+ areaStyle: { color: 'rgba(255,184,0,0.1)' }
1152
+ }
1153
+ ]
1154
+ };
1155
+ chart.setOption(option);
1156
+ chart.resize();
1157
+ }`;
1158
+ }
1159
+ function renderHeatmapInit(data) {
1160
+ const days = data.daily.slice().reverse();
1161
+ const heatData = days.map((d) => [d.day, Math.log10(d.totalTokens + 1)]);
1162
+ const minDate = days.length > 0 ? days[0].day : "";
1163
+ const maxDate = days.length > 0 ? days[days.length - 1].day : "";
1164
+ return `
1165
+ var heatData = ${JSON.stringify(heatData)};
1166
+
1167
+ function initHeatmapChart() {
1168
+ var el = document.getElementById('heatmap-chart');
1169
+ if (!el) return;
1170
+ var chart = echarts.init(el);
1171
+ window.heatmapChart = chart;
1172
+ var option = {
1173
+ tooltip: {
1174
+ formatter: function(params) {
1175
+ var val = params.value;
1176
+ var rawTokens = Math.pow(10, val[1]) - 1;
1177
+ return '<b>' + val[0] + '</b><br/>Tokens: ' + fmt(Math.round(rawTokens));
1178
+ }
1179
+ },
1180
+ visualMap: {
1181
+ min: 0,
1182
+ max: Math.max.apply(null, heatData.map(function(d) { return d[1]; })) || 5,
1183
+ calculable: true,
1184
+ orient: 'horizontal',
1185
+ left: 'center',
1186
+ bottom: 10,
1187
+ textStyle: { color: '#B0B0C0' },
1188
+ inRange: {
1189
+ color: ['#0C0C0E', '#1a3a2a', '#00F593', '#00D1FF', '#B545FF']
1190
+ }
1191
+ },
1192
+ calendar: {
1193
+ left: 30,
1194
+ right: 30,
1195
+ top: 20,
1196
+ bottom: 60,
1197
+ range: ['${minDate}', '${maxDate}'],
1198
+ splitLine: { lineStyle: { color: '#2A2A35' } },
1199
+ dayLabel: { color: '#B0B0C0' },
1200
+ monthLabel: { color: '#B0B0C0' },
1201
+ yearLabel: { color: '#B0B0C0' },
1202
+ itemStyle: { color: '#16161A', borderColor: '#0C0C0E', borderWidth: 2 }
1203
+ },
1204
+ series: [{
1205
+ type: 'heatmap',
1206
+ coordinateSystem: 'calendar',
1207
+ data: heatData
1208
+ }]
1209
+ };
1210
+ chart.setOption(option);
1211
+ chart.resize();
1212
+ }`;
1213
+ }
1214
+ function generateUsageHtml(data) {
1215
+ const metaStr = renderMeta(data);
1216
+ const kpiStr = renderKpiCards(data);
1217
+ const modelChartVisible = data.models.filter((m) => m.totalTokens >= 1e6).length > 0;
1218
+ const modelChartJs = modelChartVisible ? renderModelChartInit(data) : "";
1219
+ const scatterChartJs = renderScatterChartInit(data);
1220
+ const providerStr = renderProviderCards(data);
1221
+ const modelAnalyticsStr = renderModelAnalyticsSection(data);
1222
+ const dailyChartJs = renderDailyTrendInit(data);
1223
+ const heatmapJs = renderHeatmapInit(data);
1224
+ const hasPerf = data.perfSummary.some(
1225
+ (p) => p.requestCount > 0 && p.totalInput + p.totalOutput + p.totalCacheRead + p.totalCacheWrite > 0
1226
+ );
1227
+ const jsonData = JSON.stringify(data);
1228
+ return `<!DOCTYPE html>
1229
+ <html lang="en">
1230
+ <head>
1231
+ <meta charset="UTF-8">
1232
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1233
+ <title>TokenWatch Usage Report</title>
1234
+ <style>
1235
+ /* \u4F7F\u7528\u7CFB\u7EDF\u5B57\u4F53 stack\uFF0C\u65E0\u9700\u52A0\u8F7D\u5916\u90E8\u5B57\u4F53 */
1236
+ :root {
1237
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
1238
+ --font-mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', 'Monaco', monospace;
1239
+ }
1240
+ </style>
1241
+ <!-- ECharts: \u591A CDN fallback (staticfile -> bootcdn -> cdnjs -> unpkg -> jsDelivr) -->
1242
+ <script>
1243
+ (function() {
1244
+ var cdns = [
1245
+ 'https://cdn.staticfile.org/echarts/5.5.0/echarts.min.js',
1246
+ 'https://cdn.bootcdn.net/ajax/libs/echarts/5.5.0/echarts.min.js',
1247
+ 'https://cdnjs.cloudflare.com/ajax/libs/echarts/5.5.0/echarts.min.js',
1248
+ 'https://unpkg.com/echarts@5.5.0/dist/echarts.min.js',
1249
+ 'https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js'
1250
+ ];
1251
+ var idx = 0;
1252
+ function loadNext() {
1253
+ if (idx >= cdns.length) {
1254
+ var err = document.getElementById('echarts-error');
1255
+ if (err) err.style.display = 'block';
1256
+ return;
1257
+ }
1258
+ var s = document.createElement('script');
1259
+ s.src = cdns[idx++];
1260
+ s.onload = function() {
1261
+ if (typeof window.tryInitCharts === 'function') {
1262
+ window.tryInitCharts();
1263
+ }
1264
+ };
1265
+ s.onerror = loadNext;
1266
+ document.head.appendChild(s);
1267
+ }
1268
+ loadNext();
1269
+ })();
1270
+ </script>
1271
+ <style>
1272
+ :root {
1273
+ --bg: #0C0C0E;
1274
+ --card: #16161A;
1275
+ --border: #2A2A35;
1276
+ --text: #E0E0F0;
1277
+ --text-dim: #B0B0C0;
1278
+ --cache: #00F593;
1279
+ --input: #00D1FF;
1280
+ --output: #B545FF;
1281
+ --tps: #FFB800;
1282
+ --radius: 8px;
1283
+ }
1284
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1285
+ body {
1286
+ background: var(--bg);
1287
+ color: var(--text);
1288
+ font-family: var(--font-sans);
1289
+ font-size: 14px;
1290
+ line-height: 1.5;
1291
+ min-height: 100vh;
1292
+ }
1293
+ .container { max-width: 1400px; margin: 0 auto; padding: 24px 20px; }
1294
+ .header {
1295
+ display: flex; justify-content: space-between; align-items: center;
1296
+ padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 24px;
1297
+ }
1298
+ .header h1 { font-size: 22px; font-weight: 600; color: var(--text); }
1299
+ .header h1 span { color: var(--input); }
1300
+ .header .meta { font-size: 12px; color: var(--text-dim); font-family: var(--font-mono); }
1301
+
1302
+ .kpi-row { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin-bottom: 24px; }
1303
+ .kpi-card {
1304
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1305
+ padding: 16px; text-align: center;
1306
+ }
1307
+ .kpi-card.kpi-glow { box-shadow: 0 0 20px rgba(0,245,147,0.15); border-color: var(--cache); }
1308
+ .kpi-label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
1309
+ .kpi-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); color: var(--text); }
1310
+
1311
+ .section { margin-bottom: 28px; }
1312
+ .section-title {
1313
+ font-size: 16px; font-weight: 600; margin-bottom: 12px;
1314
+ padding-bottom: 6px; border-bottom: 1px solid var(--border);
1315
+ }
1316
+ .chart-box {
1317
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1318
+ padding: 12px; height: 400px;
1319
+ }
1320
+
1321
+ .tab-bar { display: flex; gap: 4px; margin-bottom: 12px; }
1322
+ .tab-btn {
1323
+ background: var(--card); border: 1px solid var(--border); color: var(--text-dim);
1324
+ padding: 6px 18px; border-radius: 4px 4px 0 0; cursor: pointer; font-size: 13px; font-family: var(--font-sans);
1325
+ }
1326
+ .tab-btn:hover { border-color: var(--input); color: var(--text); }
1327
+ .tab-btn.active {
1328
+ background: var(--border); color: var(--text); border-bottom-color: var(--border);
1329
+ }
1330
+ .tab-content { display: none; }
1331
+ .tab-content.active { display: block; }
1332
+
1333
+ .provider-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
1334
+ .provider-card {
1335
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1336
+ padding: 14px;
1337
+ }
1338
+ .provider-name { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--input); }
1339
+ .provider-stat { display: flex; justify-content: space-between; font-size: 12px; padding: 2px 0; }
1340
+ .provider-stat .stat-label { color: var(--text-dim); }
1341
+ .provider-more { color: var(--text-dim); font-size: 11px; padding: 10px 4px 0; grid-column: 1 / -1; }
1342
+
1343
+ .data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
1344
+ .data-table th {
1345
+ background: var(--card); color: var(--text-dim); padding: 8px 10px;
1346
+ text-align: right; border-bottom: 1px solid var(--border); font-weight: 500;
1347
+ white-space: nowrap;
1348
+ }
1349
+ .data-table th:first-child { text-align: left; }
1350
+ .data-table td {
1351
+ padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--border);
1352
+ font-family: var(--font-mono);
1353
+ }
1354
+ .data-table td:first-child {
1355
+ text-align: left; color: var(--text); font-family: var(--font-sans);
1356
+ }
1357
+ .data-table tbody tr:hover { background: rgba(42,42,53,0.4); }
1358
+
1359
+ .pagination-ctrl {
1360
+ display: none; align-items: center; gap: 14px; justify-content: center;
1361
+ padding: 14px 0 4px;
1362
+ }
1363
+ .page-btn {
1364
+ background: var(--card); border: 1px solid var(--border); color: var(--text);
1365
+ padding: 5px 16px; border-radius: 4px; cursor: pointer;
1366
+ font-size: 12px; font-family: var(--font-sans); transition: border-color 0.15s, color 0.15s;
1367
+ }
1368
+ .page-btn:hover:not(:disabled) { border-color: var(--input); color: var(--input); }
1369
+ .page-btn:disabled { opacity: 0.35; cursor: not-allowed; }
1370
+ .page-info {
1371
+ color: var(--text-dim); font-size: 12px;
1372
+ font-family: var(--font-mono); min-width: 110px; text-align: center;
1373
+ }
1374
+
1375
+ .empty-state {
1376
+ background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
1377
+ padding: 40px; text-align: center; color: var(--text-dim);
1378
+ }
1379
+
1380
+ .footer {
1381
+ margin-top: 40px; padding: 16px 0; border-top: 1px solid var(--border);
1382
+ text-align: center; font-size: 11px; color: var(--text-dim);
1383
+ }
1384
+
1385
+ @media (max-width: 768px) {
1386
+ .kpi-row { grid-template-columns: repeat(2, 1fr); }
1387
+ .provider-row { grid-template-columns: 1fr; }
1388
+ .container { padding: 12px 10px; }
1389
+ .header { flex-direction: column; gap: 6px; align-items: flex-start; }
1390
+ .chart-box { height: 300px; }
1391
+ .data-table { font-size: 11px; }
1392
+ .data-table th, .data-table td { padding: 4px 6px; }
1393
+ }
1394
+ </style>
1395
+ </head>
1396
+ <body>
1397
+ <div id="echarts-error" style="display:none;position:fixed;top:0;left:0;right:0;padding:14px 24px;background:#7f1d1d;color:#fca5a5;font-size:13px;z-index:9999;text-align:center">
1398
+ \u26A0\uFE0F \u56FE\u8868\u5E93\u52A0\u8F7D\u5931\u8D25\uFF08ECharts CDN \u5747\u65E0\u6CD5\u8BBF\u95EE\uFF09\u3002\u8BF7\u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5\u540E\u5237\u65B0\u9875\u9762\uFF0C\u6216\u5728\u79BB\u7EBF\u73AF\u5883\u4E0B\u5C06 ECharts JS \u6587\u4EF6\u4E0B\u8F7D\u5230\u672C\u5730\u540E\u624B\u52A8\u5F15\u5165\u3002
1399
+ </div>
1400
+ <div class="container">
1401
+ <div class="header">
1402
+ <h1><span>TokenWatch</span> Usage Report</h1>
1403
+ <div class="meta">${metaStr}</div>
1404
+ </div>
1405
+
1406
+ ${kpiStr}
1407
+
1408
+ <div class="section">
1409
+ <div class="section-title">Model Comparison Matrix</div>
1410
+ ${modelChartVisible ? '<div class="chart-box" id="model-chart"></div>' : '<div class="empty-state">No models with &ge;1M tokens in this period.</div>'}
1411
+ </div>
1412
+
1413
+ <div class="section">
1414
+ <div class="section-title">Provider Summary</div>
1415
+ <div class="provider-row">${providerStr}</div>
1416
+ </div>
1417
+
1418
+ ${modelAnalyticsStr}
1419
+
1420
+ <div class="section">
1421
+ <div class="section-title">Efficiency vs Cost</div>
1422
+ ${hasPerf ? '<div class="chart-box" id="scatter-chart"></div>' : '<div class="empty-state">No performance data available for this period.</div>'}
1423
+ </div>
1424
+
1425
+ <div class="section">
1426
+ <div class="section-title">Usage Timeline</div>
1427
+ <div class="tab-bar">
1428
+ <button class="tab-btn active" data-tab="daily" onclick="switchTab('daily')">Daily Trend</button>
1429
+ <button class="tab-btn" data-tab="heatmap" onclick="switchTab('heatmap')">Heatmap</button>
1430
+ </div>
1431
+ <div id="tab-daily" class="tab-content active">
1432
+ <div class="chart-box" id="daily-chart"></div>
1433
+ </div>
1434
+ <div id="tab-heatmap" class="tab-content">
1435
+ <div class="chart-box" id="heatmap-chart"></div>
1436
+ </div>
1437
+ </div>
1438
+
1439
+ <div class="footer">
1440
+ Generated by TokenWatch &middot; Data: SQLite + JSONL &middot; Export: <a href="javascript:void(0)" onclick="downloadJSON()" style="color:var(--input);text-decoration:none">JSON</a> <span style="color:var(--text-dim)">(saves to browser download folder)</span>
1441
+ </div>
1442
+ </div>
1443
+
1444
+ <script id="report-data" type="application/json">${jsonData}</script>
1445
+
1446
+ <script>
1447
+ var fmt = function(v) {
1448
+ if (v == null) return '\u2014';
1449
+ if (v >= 1000000000) return (v/1000000000).toFixed(1)+'B';
1450
+ if (v >= 1000000) return (v/1000000).toFixed(1)+'M';
1451
+ if (v >= 1000) return (v/1000).toFixed(1)+'K';
1452
+ return String(v);
1453
+ };
1454
+
1455
+ // Usage Timeline tab switcher
1456
+ window.switchTab = function(name) {
1457
+ document.querySelectorAll('.tab-content').forEach(function(el) { el.classList.remove('active'); });
1458
+ document.querySelectorAll('.tab-btn[data-tab]').forEach(function(el) { el.classList.remove('active'); });
1459
+ document.getElementById('tab-' + name).classList.add('active');
1460
+ document.querySelector('[data-tab="' + name + '"]').classList.add('active');
1461
+ setTimeout(function() {
1462
+ if (name === 'daily' && window.dailyChart) window.dailyChart.resize();
1463
+ if (name === 'heatmap' && window.heatmapChart) window.heatmapChart.resize();
1464
+ }, 50);
1465
+ };
1466
+
1467
+ // Model Analytics tab switcher
1468
+ window.switchModelTab = function(name) {
1469
+ document.querySelectorAll('[data-mtab]').forEach(function(el) { el.classList.remove('active'); });
1470
+ ['model-tab-usage', 'model-tab-perf', 'model-tab-errors'].forEach(function(id) {
1471
+ var el = document.getElementById(id);
1472
+ if (el) el.classList.remove('active');
1473
+ });
1474
+ var activeTab = document.getElementById('model-tab-' + name);
1475
+ if (activeTab) activeTab.classList.add('active');
1476
+ var activeBtn = document.querySelector('[data-mtab="' + name + '"]');
1477
+ if (activeBtn) activeBtn.classList.add('active');
1478
+ };
1479
+
1480
+ // Generic paginator: hide/show tbody rows, show prev/next controls
1481
+ function initPaginator(tableId, pageSize) {
1482
+ var tbody = document.querySelector('#' + tableId + ' tbody');
1483
+ if (!tbody) return;
1484
+ var rows = Array.from(tbody.querySelectorAll('tr'));
1485
+ if (rows.length <= pageSize) return; // \u884C\u6570\u4E0D\u8D85\u8FC7\u4E00\u9875\u65F6\u65E0\u9700\u5206\u9875
1486
+ var totalPages = Math.ceil(rows.length / pageSize);
1487
+ var cur = 1;
1488
+
1489
+ function render() {
1490
+ rows.forEach(function(r, i) {
1491
+ r.style.display = (i >= (cur - 1) * pageSize && i < cur * pageSize) ? '' : 'none';
1492
+ });
1493
+ var info = document.getElementById(tableId + '-info');
1494
+ if (info) info.textContent = 'Page ' + cur + ' / ' + totalPages + ' (' + rows.length + ' rows)';
1495
+ var prevEl = document.getElementById(tableId + '-prev');
1496
+ var nextEl = document.getElementById(tableId + '-next');
1497
+ if (prevEl) prevEl.disabled = cur === 1;
1498
+ if (nextEl) nextEl.disabled = cur === totalPages;
1499
+ }
1500
+
1501
+ var prevEl = document.getElementById(tableId + '-prev');
1502
+ var nextEl = document.getElementById(tableId + '-next');
1503
+ if (prevEl) prevEl.addEventListener('click', function() { if (cur > 1) { cur--; render(); } });
1504
+ if (nextEl) nextEl.addEventListener('click', function() { if (cur < totalPages) { cur++; render(); } });
1505
+
1506
+ var ctrl = document.getElementById(tableId + '-ctrl');
1507
+ if (ctrl) ctrl.style.display = 'flex';
1508
+ render();
1509
+ }
1510
+
1511
+ ${modelChartJs}
1512
+ ${scatterChartJs}
1513
+ ${dailyChartJs}
1514
+ ${heatmapJs}
1515
+
1516
+ window.downloadJSON = function() {
1517
+ var d = document.getElementById('report-data');
1518
+ if (!d) return;
1519
+ var b = new Blob([d.textContent], { type: 'application/json' });
1520
+ var a = document.createElement('a');
1521
+ a.href = URL.createObjectURL(b);
1522
+ a.download = 'tokenwatch-data.json';
1523
+ document.body.appendChild(a);
1524
+ a.click();
1525
+ document.body.removeChild(a);
1526
+ setTimeout(function() { URL.revokeObjectURL(a.href); }, 100);
1527
+ };
1528
+
1529
+ window.tryInitCharts = function() {
1530
+ if (typeof echarts === 'undefined' || window.__chartsInitialized) return;
1531
+ window.__chartsInitialized = true;
1532
+ ${modelChartVisible ? "initModelChart();" : ""}
1533
+ ${hasPerf ? "initScatterChart();" : ""}
1534
+ initDailyChart();
1535
+ initHeatmapChart();
1536
+ };
1537
+
1538
+ document.addEventListener('DOMContentLoaded', function() {
1539
+ initPaginator('usage-table', 10);
1540
+ initPaginator('perf-table', 10);
1541
+ initPaginator('errors-table', 10);
1542
+ window.tryInitCharts();
1543
+ });
1544
+
1545
+ window.addEventListener('resize', function() {
1546
+ ${modelChartVisible ? "if (window.modelChart) window.modelChart.resize();" : ""}
1547
+ if (window.scatterChart) window.scatterChart.resize();
1548
+ if (window.dailyChart) window.dailyChart.resize();
1549
+ if (window.heatmapChart) window.heatmapChart.resize();
1550
+ });
1551
+ </script>
1552
+ </body>
1553
+ </html>`;
1554
+ }
1555
+
1556
+ // src/i18n.ts
1557
+ var zh = {
1558
+ panelTitle: "TokenWatch",
1559
+ collapse: "\u6298\u53E0",
1560
+ expand: "\u5C55\u5F00",
1561
+ sessionSummary: "\u4F1A\u8BDD\u7D2F\u8BA1",
1562
+ input: "\u8F93\u5165",
1563
+ output: "\u8F93\u51FA",
1564
+ cacheRead: "\u7F13\u5B58",
1565
+ cacheWrite: "\u7F13\u5B58\u5199",
1566
+ cacheMiss: "\u672A\u547D\u4E2D",
1567
+ hitRate: "\u547D\u4E2D\u7387",
1568
+ requests: "\u8BF7\u6C42",
1569
+ cost: "\u6210\u672C",
1570
+ trendUp: "\u2191",
1571
+ trendDown: "\u2193",
1572
+ cache: "\u7F13\u5B58",
1573
+ lat: "\u5EF6\u8FDF",
1574
+ performance: "\u6027\u80FD",
1575
+ pricing: "Pricing",
1576
+ tokenDistribution: "Token\u5206\u5E03",
1577
+ modelLabel: "\u6A21\u578B",
1578
+ provider: "\u63D0\u4F9B\u5546",
1579
+ ttft: "TTFT",
1580
+ tps: "TPS",
1581
+ latency: "\u5EF6\u8FDF",
1582
+ avg: "\u5E73\u5747",
1583
+ max: "\u6700\u5927",
1584
+ min: "\u6700\u5C0F",
1585
+ read: "\u8BFB",
1586
+ write: "\u5199",
1587
+ sessionAccumulated: "Session\u7D2F\u8BA1",
1588
+ saving: "\u8282\u7701",
1589
+ priceInput: "\u8F93\u5165",
1590
+ priceCacheRead: "\u7F13\u5B58\u8BFB",
1591
+ priceCacheWrite: "\u7F13\u5B58\u5199",
1592
+ priceOutput: "\u8F93\u51FA",
1593
+ total: "\u603B\u8BA1",
1594
+ system: "\u7CFB\u7EDF\u63D0\u793A",
1595
+ user: "\u7528\u6237",
1596
+ agent: "Agent\u6307\u4EE4",
1597
+ toolCall: "Tool\u8C03\u7528",
1598
+ toolResult: "Tool\u7ED3\u679C",
1599
+ outputTokens: "\u8F93\u51FA",
1600
+ showPerformance: "\u663E\u793A\u6027\u80FD\u6307\u6807",
1601
+ showPricing: "\u663E\u793A\u6A21\u578B\u5B9A\u4EF7",
1602
+ showTokenDistribution: "\u663E\u793AToken\u5206\u5E03",
1603
+ showTrend: "\u663E\u793A\u8D8B\u52BF\u6307\u793A\u5668",
1604
+ language: "\u8BED\u8A00",
1605
+ auto: "\u81EA\u52A8",
1606
+ cmdTitleHtml: "HTML\u62A5\u544A",
1607
+ cmdDescHtml: "\u751F\u6210\u4EA4\u4E92\u5F0FHTML\u4EEA\u8868\u76D8\uFF0C\u5C55\u793AToken\u7528\u91CF\u3001\u7F13\u5B58\u548C\u6027\u80FD\u56FE\u8868",
1608
+ cmdTitleJson: "JSON\u5BFC\u51FA",
1609
+ cmdDescJson: "\u5BFC\u51FA\u539F\u59CB\u7528\u91CF\u6570\u636E\u4E3AJSON\u6587\u4EF6",
1610
+ cmdTitleText: "\u6587\u672C\u62A5\u544A",
1611
+ cmdDescText: "\u751F\u6210\u7EAF\u6587\u672C\u62A5\u544A\u6587\u4EF6",
1612
+ cmdTitleSettings: "\u8BBE\u7F6E",
1613
+ cmdDescSettings: "\u914D\u7F6E\u4FA7\u8FB9\u680F\u663E\u793A\u9009\u9879",
1614
+ descShowPerformance: "\u5728\u4FA7\u8FB9\u680F\u663E\u793ATPS\u3001TTFT\u3001\u5EF6\u8FDF\u7B49\u6307\u6807",
1615
+ descShowPricing: "\u5728\u4FA7\u8FB9\u680F\u663E\u793A\u6210\u672C\u4F30\u7B97",
1616
+ descShowTokenDistribution: "\u5728\u4FA7\u8FB9\u680F\u663E\u793A\u8F93\u5165/\u8F93\u51FA/\u63A8\u7406Token\u7EC6\u5206",
1617
+ descShowTrend: "\u5728\u4FA7\u8FB9\u680F\u663E\u793AToken\u7528\u91CF\u8D8B\u52BF",
1618
+ settingsLanguage: "\u8BED\u8A00",
1619
+ descSettingsLanguage: "\u5207\u6362\u663E\u793A\u8BED\u8A00",
1620
+ settingsTitle: "TokenWatch\u8BBE\u7F6E",
1621
+ settingsPlaceholder: "\u5207\u6362\u8BBE\u7F6E\u9879...",
1622
+ langAuto: "\u81EA\u52A8",
1623
+ done: "\u5B8C\u6210",
1624
+ closeSettings: "\u5173\u95ED\u8BBE\u7F6E",
1625
+ menuToday: "\u4ECA\u5929",
1626
+ menu7d: "\u6700\u8FD1 7 \u5929",
1627
+ menu30d: "\u6700\u8FD1 30 \u5929",
1628
+ menuAll: "\u5168\u90E8\u65F6\u95F4"
1629
+ };
1630
+ var en = {
1631
+ panelTitle: "TokenWatch",
1632
+ collapse: "Collapse",
1633
+ expand: "Expand",
1634
+ sessionSummary: "Session",
1635
+ input: "Input",
1636
+ output: "Output",
1637
+ cacheRead: "Cache",
1638
+ cacheWrite: "C.Write",
1639
+ cacheMiss: "Cache Miss",
1640
+ hitRate: "Hit Rate",
1641
+ requests: "Req",
1642
+ cost: "Cost",
1643
+ trendUp: "\u2191",
1644
+ trendDown: "\u2193",
1645
+ cache: "Cache",
1646
+ lat: "Lat",
1647
+ performance: "Performance",
1648
+ pricing: "Pricing",
1649
+ tokenDistribution: "Token Distribution",
1650
+ modelLabel: "Model",
1651
+ provider: "Provider",
1652
+ ttft: "TTFT",
1653
+ tps: "TPS",
1654
+ latency: "Latency",
1655
+ avg: "Avg",
1656
+ max: "Max",
1657
+ min: "Min",
1658
+ read: "Read",
1659
+ write: "Write",
1660
+ sessionAccumulated: "Session Accumulated",
1661
+ saving: "Saving",
1662
+ priceInput: "Input",
1663
+ priceCacheRead: "Cache Read",
1664
+ priceCacheWrite: "Cache Write",
1665
+ priceOutput: "Output",
1666
+ total: "Total",
1667
+ system: "System",
1668
+ user: "User",
1669
+ agent: "Agent",
1670
+ toolCall: "Tool Call",
1671
+ toolResult: "Tool Result",
1672
+ outputTokens: "Output",
1673
+ showPerformance: "Show Performance",
1674
+ showPricing: "Show Pricing",
1675
+ showTokenDistribution: "Show Token Distribution",
1676
+ showTrend: "Show Trend",
1677
+ language: "Language",
1678
+ auto: "Auto",
1679
+ cmdTitleHtml: "HTML Report",
1680
+ cmdDescHtml: "Generate interactive HTML dashboard with token usage, cache, and performance charts",
1681
+ cmdTitleJson: "JSON Export",
1682
+ cmdDescJson: "Export raw usage data as JSON file",
1683
+ cmdTitleText: "Text Report",
1684
+ cmdDescText: "Generate plain text report file",
1685
+ cmdTitleSettings: "Settings",
1686
+ cmdDescSettings: "Configure sidebar display options",
1687
+ descShowPerformance: "Display TPS, TTFT, latency metrics in sidebar",
1688
+ descShowPricing: "Display cost estimates in sidebar",
1689
+ descShowTokenDistribution: "Display input/output/reasoning token breakdown in sidebar",
1690
+ descShowTrend: "Display token usage trend in sidebar",
1691
+ settingsLanguage: "Language",
1692
+ descSettingsLanguage: "Switch display language",
1693
+ settingsTitle: "TokenWatch Settings",
1694
+ settingsPlaceholder: "Toggle settings...",
1695
+ langAuto: "Auto",
1696
+ done: "Done",
1697
+ closeSettings: "Close settings",
1698
+ menuToday: "Today",
1699
+ menu7d: "Last 7 Days",
1700
+ menu30d: "Last 30 Days",
1701
+ menuAll: "All Time"
1702
+ };
1703
+ var currentLang = detectLanguage();
1704
+ function detectLanguage() {
1705
+ try {
1706
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
1707
+ if (locale.startsWith("zh")) return "zh";
1708
+ } catch {
1709
+ }
1710
+ return "en";
1711
+ }
1712
+ function setLanguage(lang) {
1713
+ if (lang === "auto") {
1714
+ currentLang = detectLanguage();
1715
+ } else {
1716
+ currentLang = lang;
1717
+ }
1718
+ }
1719
+ function t(key) {
1720
+ const table2 = currentLang === "zh" ? zh : en;
1721
+ return table2[key] ?? key;
1722
+ }
1723
+
1724
+ // src/perf-tracker.ts
1725
+ import { appendFileSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1726
+ import { join as join3 } from "node:path";
1727
+ import { homedir as homedir2 } from "node:os";
1728
+ import { existsSync as existsSync2, statSync } from "node:fs";
1729
+
1730
+ // src/stats-store.ts
1731
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
1732
+ import { join as join2 } from "node:path";
1733
+ import { homedir } from "node:os";
1734
+ var STATS_PATH = join2(homedir(), ".opencode", "tokenwatch-stats.json");
1735
+ var LOG_PATH = join2(homedir(), ".opencode", "tokenwatch.jsonl");
1736
+ var RESERVOIR_SIZE = 500;
1737
+ var CURRENT_VERSION = 1;
1738
+ function loadStatsFile() {
1739
+ try {
1740
+ if (!existsSync(STATS_PATH)) {
1741
+ return { version: CURRENT_VERSION, updatedAt: "", migratedFromLogs: false, models: {} };
1742
+ }
1743
+ const content = readFileSync(STATS_PATH, "utf-8");
1744
+ const parsed = JSON.parse(content);
1745
+ if (parsed?.version === CURRENT_VERSION && parsed.models) return parsed;
1746
+ } catch {
1747
+ }
1748
+ return { version: CURRENT_VERSION, updatedAt: "", migratedFromLogs: false, models: {} };
1749
+ }
1750
+ function saveStatsFile(file) {
1751
+ try {
1752
+ file.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1753
+ writeFileSync(STATS_PATH, JSON.stringify(file), "utf-8");
1754
+ } catch {
1755
+ }
1756
+ }
1757
+ function reservoirAdd(reservoir, value, totalCount) {
1758
+ if (reservoir.length < RESERVOIR_SIZE) {
1759
+ return [...reservoir, value];
1760
+ }
1761
+ const j = Math.floor(Math.random() * totalCount);
1762
+ if (j < RESERVOIR_SIZE) {
1763
+ const next = [...reservoir];
1764
+ next[j] = value;
1765
+ return next;
1766
+ }
1767
+ return reservoir;
1768
+ }
1769
+ function applyEntryToModels(models, entry) {
1770
+ const key = entry.model;
1771
+ let s = models[key];
1772
+ if (!s) {
1773
+ s = {
1774
+ model: entry.model,
1775
+ providerID: entry.providerID,
1776
+ requestCount: 0,
1777
+ ttftCount: 0,
1778
+ tpsCount: 0,
1779
+ latencyCount: 0,
1780
+ totalInput: 0,
1781
+ totalOutput: 0,
1782
+ totalCacheRead: 0,
1783
+ totalCacheWrite: 0,
1784
+ totalCost: 0,
1785
+ avgTTFT: null,
1786
+ maxTTFT: null,
1787
+ minTTFT: null,
1788
+ avgTPS: null,
1789
+ maxTPS: null,
1790
+ minTPS: null,
1791
+ avgLatency: null,
1792
+ maxLatency: null,
1793
+ minLatency: null,
1794
+ ttftReservoir: [],
1795
+ latencyReservoir: []
1796
+ };
1797
+ models[key] = s;
1798
+ }
1799
+ s.requestCount++;
1800
+ s.totalInput += entry.inputTokens;
1801
+ s.totalOutput += entry.outputTokens;
1802
+ s.totalCacheRead += entry.cacheReadTokens;
1803
+ s.totalCacheWrite += entry.cacheWriteTokens;
1804
+ s.totalCost += entry.cost;
1805
+ if (entry.ttft_ms != null) {
1806
+ s.ttftCount++;
1807
+ const c = s.ttftCount;
1808
+ s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
1809
+ s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
1810
+ s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
1811
+ s.ttftReservoir = reservoirAdd(s.ttftReservoir, entry.ttft_ms, s.ttftCount);
1812
+ }
1813
+ if (entry.tps != null) {
1814
+ s.tpsCount++;
1815
+ const c = s.tpsCount;
1816
+ s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
1817
+ s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
1818
+ s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
1819
+ }
1820
+ if (entry.latency_ms != null) {
1821
+ s.latencyCount++;
1822
+ const c = s.latencyCount;
1823
+ s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
1824
+ s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
1825
+ s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
1826
+ s.latencyReservoir = reservoirAdd(s.latencyReservoir, entry.latency_ms, s.latencyCount);
1827
+ }
1828
+ }
1829
+ function migrateFromLogsIfNeeded(file) {
1830
+ if (file.migratedFromLogs) return false;
1831
+ if (!existsSync(LOG_PATH)) {
1832
+ file.migratedFromLogs = true;
1833
+ return true;
1834
+ }
1835
+ try {
1836
+ const content = readFileSync(LOG_PATH, "utf-8").trim();
1837
+ if (!content) {
1838
+ file.migratedFromLogs = true;
1839
+ return true;
1840
+ }
1841
+ let migrated = 0;
1842
+ for (const line of content.split("\n")) {
1843
+ if (!line) continue;
1844
+ try {
1845
+ const entry = JSON.parse(line);
1846
+ if (entry.model && entry.ts) {
1847
+ applyEntryToModels(file.models, entry);
1848
+ migrated++;
1849
+ }
1850
+ } catch {
1851
+ }
1852
+ }
1853
+ file.migratedFromLogs = true;
1854
+ if (migrated > 0) {
1855
+ ;
1856
+ file._migratedFrom = `${LOG_PATH} (${migrated} entries)`;
1857
+ }
1858
+ return true;
1859
+ } catch {
1860
+ file.migratedFromLogs = true;
1861
+ return true;
1862
+ }
1863
+ }
1864
+ function percentile(arr, p) {
1865
+ if (arr.length === 0) return null;
1866
+ if (arr.length === 1) return arr[0];
1867
+ const idx = p / 100 * (arr.length - 1);
1868
+ const lo = Math.floor(idx);
1869
+ const hi = Math.ceil(idx);
1870
+ if (lo === hi) return arr[lo];
1871
+ return arr[lo] + (arr[hi] - arr[lo]) * (idx - lo);
1872
+ }
1873
+ function updatePersistedStats(entry) {
1874
+ try {
1875
+ const file = loadStatsFile();
1876
+ applyEntryToModels(file.models, entry);
1877
+ saveStatsFile(file);
1878
+ } catch {
1879
+ }
1880
+ }
1881
+ function readPersistedStats() {
1882
+ try {
1883
+ const file = loadStatsFile();
1884
+ if (!file.migratedFromLogs) {
1885
+ file.models = {};
1886
+ migrateFromLogsIfNeeded(file);
1887
+ saveStatsFile(file);
1888
+ }
1889
+ return Object.values(file.models).map((s) => {
1890
+ const ttftArr = [...s.ttftReservoir].sort((a, b) => a - b);
1891
+ const latArr = [...s.latencyReservoir].sort((a, b) => a - b);
1892
+ const denom = s.totalInput + s.totalCacheRead;
1893
+ return {
1894
+ model: s.model,
1895
+ providerID: s.providerID,
1896
+ requestCount: s.requestCount,
1897
+ ttftCount: s.ttftCount,
1898
+ tpsCount: s.tpsCount,
1899
+ latencyCount: s.latencyCount,
1900
+ totalInput: s.totalInput,
1901
+ totalOutput: s.totalOutput,
1902
+ totalCacheRead: s.totalCacheRead,
1903
+ totalCacheWrite: s.totalCacheWrite,
1904
+ totalCost: s.totalCost,
1905
+ avgTTFT: s.avgTTFT,
1906
+ maxTTFT: s.maxTTFT,
1907
+ minTTFT: s.minTTFT,
1908
+ p50TTFT: percentile(ttftArr, 50),
1909
+ p95TTFT: percentile(ttftArr, 95),
1910
+ p99TTFT: percentile(ttftArr, 99),
1911
+ avgTPS: s.avgTPS,
1912
+ maxTPS: s.maxTPS,
1913
+ minTPS: s.minTPS,
1914
+ avgLatency: s.avgLatency,
1915
+ maxLatency: s.maxLatency,
1916
+ minLatency: s.minLatency,
1917
+ p50Latency: percentile(latArr, 50),
1918
+ p95Latency: percentile(latArr, 95),
1919
+ p99Latency: percentile(latArr, 99),
1920
+ cacheHitRate: denom > 0 ? s.totalCacheRead / denom * 100 : null
1921
+ };
1922
+ });
1923
+ } catch {
1924
+ return [];
1925
+ }
1926
+ }
1927
+
1928
+ // src/perf-tracker.ts
1929
+ var LOG_PATH2 = join3(homedir2(), ".opencode", "tokenwatch.jsonl");
1930
+ var PerfTracker = class {
1931
+ firstPartTimes = /* @__PURE__ */ new Map();
1932
+ statsMap = /* @__PURE__ */ new Map();
1933
+ /** 原始样本串,用于分位数计算,不持久化 */
1934
+ ttftSamples = /* @__PURE__ */ new Map();
1935
+ latencySamples = /* @__PURE__ */ new Map();
1936
+ handlePartUpdated(event) {
1937
+ if (!event.time?.start || !event.message_id) return;
1938
+ const cur = this.firstPartTimes.get(event.message_id) ?? Number.POSITIVE_INFINITY;
1939
+ if (event.time.start < cur) {
1940
+ this.firstPartTimes.set(event.message_id, event.time.start);
1941
+ }
1942
+ }
1943
+ handleMessageUpdated(event) {
1944
+ const info = event.properties?.info;
1945
+ if (!info || info.role !== "assistant") return;
1946
+ if (!info.time?.completed) return;
1947
+ const messageID = info.id ?? "";
1948
+ const created = info.time.created;
1949
+ const completed = info.time.completed;
1950
+ if (!created || !completed) {
1951
+ this.firstPartTimes.delete(messageID);
1952
+ return;
1953
+ }
1954
+ const sessionID = info.sessionID ?? "";
1955
+ const providerID = info.providerID ?? "unknown";
1956
+ const modelID = info.modelID ?? "unknown";
1957
+ const model = `${providerID}/${modelID}`;
1958
+ const tokens = info.tokens;
1959
+ const inputTokens = tokens?.input ?? 0;
1960
+ const outputTokens = tokens?.output ?? 0;
1961
+ const reasoningTokens = tokens?.reasoning ?? 0;
1962
+ const cacheRead = tokens?.cache?.read ?? 0;
1963
+ const cacheWrite = tokens?.cache?.write ?? 0;
1964
+ const cost = info.cost ?? 0;
1965
+ if (inputTokens + outputTokens + cacheRead + cacheWrite === 0) {
1966
+ this.firstPartTimes.delete(messageID);
1967
+ return;
1968
+ }
1969
+ const firstPart = this.firstPartTimes.get(messageID) ?? null;
1970
+ const latencyMs = completed - created;
1971
+ const ttftMs = firstPart !== null ? firstPart - created : null;
1972
+ const genMs = firstPart !== null ? completed - firstPart : null;
1973
+ const tps = genMs !== null && genMs > 0 && outputTokens > 0 ? outputTokens / genMs * 1e3 : null;
1974
+ this.firstPartTimes.delete(messageID);
1975
+ const entry = {
1976
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
1977
+ model,
1978
+ providerID,
1979
+ modelID,
1980
+ sessionID,
1981
+ ttft_ms: ttftMs,
1982
+ tps,
1983
+ // 只在有可靠 genMs 时才有值
1984
+ latency_ms: latencyMs,
1985
+ inputTokens,
1986
+ outputTokens,
1987
+ reasoningTokens,
1988
+ cacheReadTokens: cacheRead,
1989
+ cacheWriteTokens: cacheWrite,
1990
+ cost
1991
+ };
1992
+ this.appendLog(entry);
1993
+ this.updateStats(model, entry);
1994
+ }
1995
+ appendLog(entry) {
1996
+ try {
1997
+ const MAX_SIZE = 5 * 1024 * 1024;
1998
+ const KEEP_LINES = 2e3;
1999
+ if (existsSync2(LOG_PATH2) && statSync(LOG_PATH2).size > MAX_SIZE) {
2000
+ const lines = readFileSync2(LOG_PATH2, "utf-8").trim().split("\n");
2001
+ writeFileSync2(LOG_PATH2, lines.slice(-KEEP_LINES).join("\n") + "\n");
2002
+ }
2003
+ appendFileSync(LOG_PATH2, JSON.stringify(entry) + "\n");
2004
+ } catch {
2005
+ }
2006
+ updatePersistedStats(entry);
2007
+ }
2008
+ handleMessageRemoved(event) {
2009
+ const mid = event.properties?.messageID ?? "";
2010
+ if (mid) {
2011
+ this.firstPartTimes.delete(mid);
2012
+ }
2013
+ }
2014
+ updateStats(model, entry) {
2015
+ let stats = this.statsMap.get(model);
2016
+ if (!stats) {
2017
+ stats = {
2018
+ model,
2019
+ providerID: entry.providerID,
2020
+ requestCount: 0,
2021
+ ttftCount: 0,
2022
+ // Bug fix: 独立维护有效样本计数
2023
+ tpsCount: 0,
2024
+ latencyCount: 0,
2025
+ totalInput: 0,
2026
+ totalOutput: 0,
2027
+ totalCacheRead: 0,
2028
+ totalCacheWrite: 0,
2029
+ totalCost: 0,
2030
+ avgTTFT: null,
2031
+ maxTTFT: null,
2032
+ minTTFT: null,
2033
+ p50TTFT: null,
2034
+ p95TTFT: null,
2035
+ p99TTFT: null,
2036
+ avgTPS: null,
2037
+ maxTPS: null,
2038
+ minTPS: null,
2039
+ avgLatency: null,
2040
+ maxLatency: null,
2041
+ minLatency: null,
2042
+ p50Latency: null,
2043
+ p95Latency: null,
2044
+ p99Latency: null,
2045
+ cacheHitRate: null
2046
+ };
2047
+ this.statsMap.set(model, stats);
2048
+ }
2049
+ stats.requestCount++;
2050
+ stats.totalInput += entry.inputTokens;
2051
+ stats.totalOutput += entry.outputTokens;
2052
+ stats.totalCacheRead += entry.cacheReadTokens;
2053
+ stats.totalCacheWrite += entry.cacheWriteTokens;
2054
+ stats.totalCost += entry.cost;
2055
+ if (entry.ttft_ms !== null) {
2056
+ stats.ttftCount++;
2057
+ const c = stats.ttftCount;
2058
+ const prev = stats.avgTTFT;
2059
+ stats.avgTTFT = prev !== null ? prev + (entry.ttft_ms - prev) / c : entry.ttft_ms;
2060
+ stats.maxTTFT = stats.maxTTFT !== null ? Math.max(stats.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
2061
+ stats.minTTFT = stats.minTTFT !== null ? Math.min(stats.minTTFT, entry.ttft_ms) : entry.ttft_ms;
2062
+ const ttftArr = this.ttftSamples.get(model) ?? [];
2063
+ ttftArr.push(entry.ttft_ms);
2064
+ this.ttftSamples.set(model, ttftArr);
2065
+ }
2066
+ if (entry.tps !== null) {
2067
+ stats.tpsCount++;
2068
+ const c = stats.tpsCount;
2069
+ const prev = stats.avgTPS;
2070
+ stats.avgTPS = prev !== null ? prev + (entry.tps - prev) / c : entry.tps;
2071
+ stats.maxTPS = stats.maxTPS !== null ? Math.max(stats.maxTPS, entry.tps) : entry.tps;
2072
+ stats.minTPS = stats.minTPS !== null ? Math.min(stats.minTPS, entry.tps) : entry.tps;
2073
+ }
2074
+ if (entry.latency_ms !== null) {
2075
+ stats.latencyCount++;
2076
+ const c = stats.latencyCount;
2077
+ const prev = stats.avgLatency;
2078
+ stats.avgLatency = prev !== null ? prev + (entry.latency_ms - prev) / c : entry.latency_ms;
2079
+ stats.maxLatency = stats.maxLatency !== null ? Math.max(stats.maxLatency, entry.latency_ms) : entry.latency_ms;
2080
+ stats.minLatency = stats.minLatency !== null ? Math.min(stats.minLatency, entry.latency_ms) : entry.latency_ms;
2081
+ const latArr = this.latencySamples.get(model) ?? [];
2082
+ latArr.push(entry.latency_ms);
2083
+ this.latencySamples.set(model, latArr);
2084
+ }
2085
+ }
2086
+ /** 计算有序数组的指定百分位数(线性插值法) */
2087
+ percentile(sortedArr, p) {
2088
+ if (sortedArr.length === 0) return null;
2089
+ if (sortedArr.length === 1) return sortedArr[0];
2090
+ const idx = p / 100 * (sortedArr.length - 1);
2091
+ const lo = Math.floor(idx);
2092
+ const hi = Math.ceil(idx);
2093
+ if (lo === hi) return sortedArr[lo];
2094
+ return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
2095
+ }
2096
+ getSessionStats() {
2097
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
2098
+ let totalRequests = 0, totalCost = 0;
2099
+ let weightedHitSum = 0, totalReqForHit = 0;
2100
+ for (const [model, s] of this.statsMap) {
2101
+ totalInput += s.totalInput;
2102
+ totalOutput += s.totalOutput;
2103
+ totalCacheRead += s.totalCacheRead;
2104
+ totalCacheWrite += s.totalCacheWrite;
2105
+ totalRequests += s.requestCount;
2106
+ totalCost += s.totalCost;
2107
+ const ttftArr = [...this.ttftSamples.get(model) ?? []].sort((a, b) => a - b);
2108
+ s.p50TTFT = this.percentile(ttftArr, 50);
2109
+ s.p95TTFT = this.percentile(ttftArr, 95);
2110
+ s.p99TTFT = this.percentile(ttftArr, 99);
2111
+ const latArr = [...this.latencySamples.get(model) ?? []].sort((a, b) => a - b);
2112
+ s.p50Latency = this.percentile(latArr, 50);
2113
+ s.p95Latency = this.percentile(latArr, 95);
2114
+ s.p99Latency = this.percentile(latArr, 99);
2115
+ const denom = s.totalInput + s.totalCacheRead;
2116
+ s.cacheHitRate = denom > 0 ? s.totalCacheRead / denom * 100 : null;
2117
+ if (s.cacheHitRate !== null) {
2118
+ weightedHitSum += s.cacheHitRate * s.requestCount;
2119
+ totalReqForHit += s.requestCount;
2120
+ }
2121
+ }
2122
+ const weightedCacheHitRate = totalReqForHit > 0 ? weightedHitSum / totalReqForHit : null;
2123
+ return {
2124
+ models: Object.fromEntries(this.statsMap),
2125
+ totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost, weightedCacheHitRate }
2126
+ };
2127
+ }
2128
+ readLogs(last = 50) {
2129
+ try {
2130
+ if (!existsSync2(LOG_PATH2)) return [];
2131
+ const content = readFileSync2(LOG_PATH2, "utf-8").trim();
2132
+ if (!content) return [];
2133
+ const lines = content.split("\n");
2134
+ const entries = [];
2135
+ for (let i = Math.max(0, lines.length - last); i < lines.length; i++) {
2136
+ try {
2137
+ entries.push(JSON.parse(lines[i]));
2138
+ } catch {
2139
+ }
2140
+ }
2141
+ return entries;
2142
+ } catch {
2143
+ return [];
2144
+ }
2145
+ }
2146
+ reset() {
2147
+ this.firstPartTimes.clear();
2148
+ this.statsMap.clear();
2149
+ this.ttftSamples.clear();
2150
+ this.latencySamples.clear();
2151
+ }
2152
+ loadSession(sessionID) {
2153
+ this.firstPartTimes.clear();
2154
+ this.statsMap.clear();
2155
+ this.ttftSamples.clear();
2156
+ this.latencySamples.clear();
2157
+ if (!sessionID) return;
2158
+ try {
2159
+ if (!existsSync2(LOG_PATH2)) return;
2160
+ const content = readFileSync2(LOG_PATH2, "utf-8").trim();
2161
+ if (!content) return;
2162
+ const lines = content.split("\n");
2163
+ for (const line of lines) {
2164
+ if (!line) continue;
2165
+ try {
2166
+ const entry = JSON.parse(line);
2167
+ if (entry.sessionID === sessionID) {
2168
+ this.updateStats(entry.model, entry);
2169
+ }
2170
+ } catch {
2171
+ }
2172
+ }
2173
+ } catch {
2174
+ }
2175
+ }
2176
+ };
2177
+ function createPerfTracker() {
2178
+ return new PerfTracker();
2179
+ }
2180
+ function readLogs(last = 50) {
2181
+ const tracker = new PerfTracker();
2182
+ return tracker.readLogs(last);
2183
+ }
2184
+
2185
+ // src/commands.tsx
2186
+ import { existsSync as existsSync3, mkdirSync, writeFileSync as writeFileSync3 } from "node:fs";
2187
+ import { join as join4 } from "node:path";
2188
+ import { homedir as homedir3 } from "node:os";
2189
+ import { execSync } from "node:child_process";
2190
+ var DEFAULT_CONFIG = {
2191
+ sidebar: {
2192
+ showPerformance: true,
2193
+ showPricing: true,
2194
+ showTokenDistribution: true,
2195
+ showTrend: true
2196
+ },
2197
+ language: "auto"
2198
+ };
2199
+ async function registerCommands(api) {
2200
+ api.command?.register(() => [{
2201
+ value: "tokenwatch-usage",
2202
+ title: "TokenWatch",
2203
+ description: "Token usage reports, export, and settings",
2204
+ category: "Stats",
2205
+ slash: {
2206
+ name: "usage"
2207
+ },
2208
+ onSelect: async (dialog) => {
2209
+ if (dialog) showUsageMenu(api, dialog);
2210
+ }
2211
+ }]);
2212
+ }
2213
+ function ensureReportDir() {
2214
+ const dir = join4(homedir3(), ".opencode", "reports");
2215
+ if (!existsSync3(dir)) mkdirSync(dir, {
2216
+ recursive: true
2217
+ });
2218
+ return dir;
2219
+ }
2220
+ function openInBrowser(filePath) {
2221
+ try {
2222
+ const platform = process.platform;
2223
+ if (platform === "win32") execSync(`start "" "${filePath}"`, {
2224
+ windowsHide: true,
2225
+ timeout: 5e3
2226
+ });
2227
+ else if (platform === "darwin") execSync(`open "${filePath}"`, {
2228
+ timeout: 5e3
2229
+ });
2230
+ else execSync(`xdg-open "${filePath}"`, {
2231
+ timeout: 5e3
2232
+ });
2233
+ } catch {
2234
+ }
2235
+ }
2236
+ async function buildCombinedData(api, filters = {}) {
2237
+ const report = await getUsageReport(filters);
2238
+ const logs = readLogs(200);
2239
+ const perfSummary = readPersistedStats();
2240
+ const now = /* @__PURE__ */ new Date();
2241
+ const pad = (n) => String(n).padStart(2, "0");
2242
+ const meta = {
2243
+ generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
2244
+ dateRange: {
2245
+ start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "\u2014",
2246
+ end: report.daily.length > 0 ? report.daily[0].day : "\u2014"
2247
+ }
2248
+ };
2249
+ return {
2250
+ ...report,
2251
+ perfLogs: logs,
2252
+ perfSummary,
2253
+ meta
2254
+ };
2255
+ }
2256
+ function getRangeSlug(filters, presetTag) {
2257
+ if (presetTag) return presetTag;
2258
+ if (!filters.startDate && !filters.endDate) return "all";
2259
+ if (filters.startDate && filters.endDate) {
2260
+ if (filters.startDate === filters.endDate) {
2261
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2262
+ if (filters.startDate === today) return "today";
2263
+ return filters.startDate;
2264
+ }
2265
+ return `${filters.startDate.replace(/-/g, "")}_${filters.endDate.replace(/-/g, "")}`;
2266
+ }
2267
+ if (filters.startDate) return `from_${filters.startDate.replace(/-/g, "")}`;
2268
+ if (filters.endDate) return `until_${filters.endDate.replace(/-/g, "")}`;
2269
+ return "custom";
2270
+ }
2271
+ function generateUniqueReportPath(dir, rangeSlug) {
2272
+ const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2273
+ const baseName = `tokenwatch-${rangeSlug}-${dateStr}`;
2274
+ let targetPath = join4(dir, `${baseName}.html`);
2275
+ if (!existsSync3(targetPath)) {
2276
+ return targetPath;
2277
+ }
2278
+ const now = /* @__PURE__ */ new Date();
2279
+ const pad = (n) => String(n).padStart(2, "0");
2280
+ const timeSuffix = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
2281
+ targetPath = join4(dir, `${baseName}_${timeSuffix}.html`);
2282
+ let counter = 1;
2283
+ while (existsSync3(targetPath)) {
2284
+ targetPath = join4(dir, `${baseName}_${timeSuffix}_${counter}.html`);
2285
+ counter++;
2286
+ }
2287
+ return targetPath;
2288
+ }
2289
+ async function showHtmlReport(api, filters = {}, presetTag) {
2290
+ try {
2291
+ const data = await buildCombinedData(api, filters);
2292
+ const html = generateUsageHtml(data);
2293
+ const dir = ensureReportDir();
2294
+ const rangeSlug = getRangeSlug(filters, presetTag);
2295
+ const filePath = generateUniqueReportPath(dir, rangeSlug);
2296
+ writeFileSync3(filePath, html, "utf-8");
2297
+ api.ui.toast?.({
2298
+ message: `Report: ${filePath}`,
2299
+ variant: "info"
2300
+ });
2301
+ openInBrowser(filePath);
2302
+ } catch (err) {
2303
+ const msg = err instanceof Error ? err.message : String(err);
2304
+ api.ui.toast?.({
2305
+ message: `Error: ${msg}`,
2306
+ variant: "error"
2307
+ });
2308
+ }
2309
+ }
2310
+ function showHtmlReportRangeMenu(api, dialog) {
2311
+ dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
2312
+ get title() {
2313
+ return t("cmdTitleHtml");
2314
+ },
2315
+ placeholder: "Select date range...",
2316
+ get options() {
2317
+ return [{
2318
+ title: t("menuToday"),
2319
+ value: "today",
2320
+ onSelect: () => {
2321
+ dialog.clear();
2322
+ const d = /* @__PURE__ */ new Date();
2323
+ const pad = (n) => String(n).padStart(2, "0");
2324
+ const s = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
2325
+ showHtmlReport(api, {
2326
+ startDate: s,
2327
+ endDate: s
2328
+ }, "today");
2329
+ }
2330
+ }, {
2331
+ title: t("menu7d"),
2332
+ value: "7d",
2333
+ onSelect: () => {
2334
+ dialog.clear();
2335
+ showHtmlReport(api, getPresetRange("7d"), "7d");
2336
+ }
2337
+ }, {
2338
+ title: t("menu30d"),
2339
+ value: "30d",
2340
+ onSelect: () => {
2341
+ dialog.clear();
2342
+ showHtmlReport(api, getPresetRange("30d"), "30d");
2343
+ }
2344
+ }, {
2345
+ title: t("menuAll"),
2346
+ value: "all",
2347
+ onSelect: () => {
2348
+ dialog.clear();
2349
+ showHtmlReport(api, getPresetRange("all"), "all");
2350
+ }
2351
+ }];
2352
+ },
2353
+ flat: true
2354
+ }));
2355
+ }
2356
+ function showUsageMenu(api, dialog) {
2357
+ try {
2358
+ setLanguage(loadConfigFromStore(api).language);
2359
+ } catch {
2360
+ }
2361
+ dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
2362
+ get title() {
2363
+ return t("panelTitle");
2364
+ },
2365
+ placeholder: "Select an action...",
2366
+ get options() {
2367
+ return [{
2368
+ title: `${t("cmdTitleHtml")} \u25B8`,
2369
+ value: "html",
2370
+ description: t("cmdDescHtml"),
2371
+ onSelect: () => showHtmlReportRangeMenu(api, dialog)
2372
+ }, {
2373
+ title: t("cmdTitleJson"),
2374
+ value: "json",
2375
+ description: t("cmdDescJson"),
2376
+ onSelect: () => {
2377
+ dialog.clear();
2378
+ showJsonExport(api);
2379
+ }
2380
+ }, {
2381
+ title: t("cmdTitleText"),
2382
+ value: "text",
2383
+ description: t("cmdDescText"),
2384
+ onSelect: () => {
2385
+ dialog.clear();
2386
+ showTextReport(api);
2387
+ }
2388
+ }, {
2389
+ title: `${t("cmdTitleSettings")} \u25B8`,
2390
+ value: "settings",
2391
+ description: t("cmdDescSettings"),
2392
+ onSelect: () => showSettingsDialog(api, dialog)
2393
+ }];
2394
+ },
2395
+ flat: true
2396
+ }));
2397
+ }
2398
+ async function showJsonExport(api) {
2399
+ try {
2400
+ const report = await getUsageReport({});
2401
+ const dir = ensureReportDir();
2402
+ const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2403
+ const filePath = join4(dir, `tokenwatch-${dateStr}.json`);
2404
+ writeFileSync3(filePath, JSON.stringify(report, null, 2), "utf-8");
2405
+ api.ui.toast?.({
2406
+ message: `JSON: ${filePath}`,
2407
+ variant: "info"
2408
+ });
2409
+ } catch (err) {
2410
+ const msg = err instanceof Error ? err.message : String(err);
2411
+ api.ui.toast?.({
2412
+ message: `Error: ${msg}`,
2413
+ variant: "error"
2414
+ });
2415
+ }
2416
+ }
2417
+ async function showTextReport(api) {
2418
+ try {
2419
+ const report = await getUsageReport({});
2420
+ const formatted = formatUsageReport(report);
2421
+ const dir = ensureReportDir();
2422
+ const dateStr = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2423
+ const filePath = join4(dir, `tokenwatch-${dateStr}.md`);
2424
+ writeFileSync3(filePath, formatted, "utf-8");
2425
+ api.ui.toast?.({
2426
+ message: `Report saved to ${filePath}`,
2427
+ variant: "info"
2428
+ });
2429
+ } catch (err) {
2430
+ const msg = err instanceof Error ? err.message : String(err);
2431
+ api.ui.toast?.({
2432
+ message: `Error: ${msg}`,
2433
+ variant: "error"
2434
+ });
2435
+ }
2436
+ }
2437
+ function saveConfigToStore(api, cfg) {
2438
+ api.kv?.set?.("tokenwatch-config", cfg);
2439
+ }
2440
+ var lastSelectedSetting;
2441
+ function showSettingsDialog(api, dialog) {
2442
+ if (!dialog) return;
2443
+ const reopen = (value) => {
2444
+ lastSelectedSetting = value;
2445
+ setTimeout(() => showSettingsDialog(api, dialog), 0);
2446
+ };
2447
+ const cfg = loadConfigFromStore(api).sidebar;
2448
+ dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
2449
+ get title() {
2450
+ return t("settingsTitle");
2451
+ },
2452
+ get placeholder() {
2453
+ return t("settingsPlaceholder");
2454
+ },
2455
+ get options() {
2456
+ return [{
2457
+ title: `${cfg.showPerformance ? "\u2713 " : " "}${t("showPerformance")}`,
2458
+ value: "showPerformance",
2459
+ description: t("descShowPerformance"),
2460
+ onSelect: () => {
2461
+ toggleSidebarSetting(api, "showPerformance");
2462
+ reopen("showPerformance");
2463
+ }
2464
+ }, {
2465
+ title: `${cfg.showPricing ? "\u2713 " : " "}${t("showPricing")}`,
2466
+ value: "showPricing",
2467
+ description: t("descShowPricing"),
2468
+ onSelect: () => {
2469
+ toggleSidebarSetting(api, "showPricing");
2470
+ reopen("showPricing");
2471
+ }
2472
+ }, {
2473
+ title: `${cfg.showTokenDistribution ? "\u2713 " : " "}${t("showTokenDistribution")}`,
2474
+ value: "showTokenDistribution",
2475
+ description: t("descShowTokenDistribution"),
2476
+ onSelect: () => {
2477
+ toggleSidebarSetting(api, "showTokenDistribution");
2478
+ reopen("showTokenDistribution");
2479
+ }
2480
+ }, {
2481
+ title: `${cfg.showTrend ? "\u2713 " : " "}${t("showTrend")}`,
2482
+ value: "showTrend",
2483
+ description: t("descShowTrend"),
2484
+ onSelect: () => {
2485
+ toggleSidebarSetting(api, "showTrend");
2486
+ reopen("showTrend");
2487
+ }
2488
+ }, {
2489
+ title: `${t("settingsLanguage")} \u25B8`,
2490
+ value: "language",
2491
+ description: t("descSettingsLanguage"),
2492
+ onSelect: () => showLanguageMenu(api, dialog)
2493
+ }, {
2494
+ title: t("done"),
2495
+ value: "done",
2496
+ description: t("closeSettings"),
2497
+ onSelect: () => {
2498
+ lastSelectedSetting = void 0;
2499
+ dialog.clear();
2500
+ }
2501
+ }];
2502
+ },
2503
+ flat: true,
2504
+ current: lastSelectedSetting
2505
+ }));
2506
+ }
2507
+ function showLanguageMenu(api, dialog) {
2508
+ const current = api.kv?.get?.("tokenwatch-config")?.language ?? "auto";
2509
+ dialog.replace(() => _$createComponent(api.ui.DialogSelect, {
2510
+ get title() {
2511
+ return t("settingsLanguage");
2512
+ },
2513
+ get placeholder() {
2514
+ return t("settingsLanguage");
2515
+ },
2516
+ get options() {
2517
+ return [{
2518
+ title: `${current === "auto" ? "\u2713 " : " "}${t("langAuto")}`,
2519
+ value: "auto",
2520
+ description: "\u81EA\u52A8\u68C0\u6D4B / Auto-detect",
2521
+ onSelect: () => {
2522
+ setLanguageSetting(api, "auto");
2523
+ lastSelectedSetting = "language";
2524
+ dialog.clear();
2525
+ showSettingsDialog(api, dialog);
2526
+ }
2527
+ }, {
2528
+ title: `${current === "zh" ? "\u2713 " : " "}\u4E2D\u6587`,
2529
+ value: "zh",
2530
+ description: "\u7B80\u4F53\u4E2D\u6587",
2531
+ onSelect: () => {
2532
+ setLanguageSetting(api, "zh");
2533
+ lastSelectedSetting = "language";
2534
+ dialog.clear();
2535
+ showSettingsDialog(api, dialog);
2536
+ }
2537
+ }, {
2538
+ title: `${current === "en" ? "\u2713 " : " "}English`,
2539
+ value: "en",
2540
+ description: "English",
2541
+ onSelect: () => {
2542
+ setLanguageSetting(api, "en");
2543
+ lastSelectedSetting = "language";
2544
+ dialog.clear();
2545
+ showSettingsDialog(api, dialog);
2546
+ }
2547
+ }];
2548
+ },
2549
+ flat: true
2550
+ }));
2551
+ }
2552
+ function setLanguageSetting(api, lang) {
2553
+ setLanguage(lang);
2554
+ const current = loadConfigFromStore(api);
2555
+ current.language = lang;
2556
+ saveConfigToStore(api, current);
2557
+ const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
2558
+ api.kv?.set?.("tokenwatch-config-version", v);
2559
+ }
2560
+ function toggleSidebarSetting(api, key) {
2561
+ const current = loadConfigFromStore(api);
2562
+ current.sidebar[key] = !current.sidebar[key];
2563
+ saveConfigToStore(api, current);
2564
+ const v = (api.kv?.get?.("tokenwatch-config-version") ?? 0) + 1;
2565
+ api.kv?.set?.("tokenwatch-config-version", v);
2566
+ }
2567
+ function loadConfigFromStore(api) {
2568
+ const base = {
2569
+ sidebar: {
2570
+ ...DEFAULT_CONFIG.sidebar
2571
+ },
2572
+ language: DEFAULT_CONFIG.language
2573
+ };
2574
+ try {
2575
+ const stored = api.kv?.get?.("tokenwatch-config");
2576
+ if (stored) {
2577
+ if (stored.sidebar) Object.assign(base.sidebar, stored.sidebar);
2578
+ if (stored.language) base.language = stored.language;
2579
+ }
2580
+ } catch {
2581
+ }
2582
+ return base;
2583
+ }
2584
+
2585
+ // src/sidebar.tsx
2586
+ import { createComponent as _$createComponent2 } from "@opentui/solid";
2587
+ import { effect as _$effect } from "@opentui/solid";
2588
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
2589
+ import { insertNode as _$insertNode } from "@opentui/solid";
2590
+ import { insert as _$insert } from "@opentui/solid";
2591
+ import { memo as _$memo } from "@opentui/solid";
2592
+ import { setProp as _$setProp } from "@opentui/solid";
2593
+ import { use as _$use } from "@opentui/solid";
2594
+ import { createElement as _$createElement } from "@opentui/solid";
2595
+ import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
2596
+ import { RGBA } from "@opentui/core";
2597
+ var DEFAULT_CONFIG2 = {
2598
+ sidebar: {
2599
+ showPerformance: true,
2600
+ showPricing: true,
2601
+ showTokenDistribution: true,
2602
+ showTrend: true
2603
+ },
2604
+ language: "auto"
2605
+ };
2606
+ function progressBarWidth(percent, width) {
2607
+ if (percent >= 100) return width;
2608
+ return Math.floor(percent / 100 * width);
2609
+ }
2610
+ function progressFilled(percent, width) {
2611
+ return "\u2588".repeat(Math.max(0, progressBarWidth(percent, width)));
2612
+ }
2613
+ function progressRemaining(percent, width) {
2614
+ return "\u2591".repeat(Math.max(0, width - progressBarWidth(percent, width)));
2615
+ }
2616
+ function getVisualWidth2(str) {
2617
+ let w = 0;
2618
+ for (const c of str) {
2619
+ const code = c.codePointAt(0) ?? 0;
2620
+ if (code >= 19968 && code <= 40959 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55203 || code >= 4352 && code <= 4607 || code >= 11904 && code <= 12031) {
2621
+ w += 2;
2622
+ } else {
2623
+ w += 1;
2624
+ }
2625
+ }
2626
+ return w;
2627
+ }
2628
+ function centerAlign(text, width) {
2629
+ const visualW = getVisualWidth2(text);
2630
+ if (visualW >= width) return text;
2631
+ const left = Math.floor((width - visualW) / 2);
2632
+ const right = width - visualW - left;
2633
+ return " ".repeat(left) + text + " ".repeat(right);
2634
+ }
2635
+ function hitRateColor(rate) {
2636
+ if (rate >= 85) return RGBA.fromInts(76, 175, 80, 255);
2637
+ if (rate >= 70) return RGBA.fromInts(255, 193, 7, 255);
2638
+ return RGBA.fromInts(244, 67, 54, 255);
2639
+ }
2640
+ function distRoleColor(role) {
2641
+ const map = {
2642
+ system: RGBA.fromInts(130, 80, 255, 255),
2643
+ user: RGBA.fromInts(88, 166, 255, 255),
2644
+ toolCall: RGBA.fromInts(210, 153, 34, 255),
2645
+ toolResult: RGBA.fromInts(219, 109, 40, 255),
2646
+ output: RGBA.fromInts(63, 185, 80, 255),
2647
+ other: RGBA.fromInts(72, 79, 88, 255)
2648
+ };
2649
+ return map[role] ?? RGBA.fromInts(72, 79, 88, 255);
2650
+ }
2651
+ function estimateTokens(text) {
2652
+ if (!text || text.length === 0) return 0;
2653
+ let ascii = 0, cjk = 0;
2654
+ for (const c of text) {
2655
+ const code = c.codePointAt(0) ?? 0;
2656
+ if (code >= 19968 && code <= 40959 || code >= 12352 && code <= 12543 || code >= 44032 && code <= 55203 || code >= 4352 && code <= 4607 || code >= 11904 && code <= 12031) cjk++;
2657
+ else ascii++;
2658
+ }
2659
+ const trimmed = text.trimStart();
2660
+ const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("[")) && /"[^"]+"\s*:/.test(text);
2661
+ const codeLike = !jsonLike && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
2662
+ const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
2663
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
2664
+ }
2665
+ function loadCollapseState(api) {
2666
+ try {
2667
+ return api.kv?.get?.("tokenwatch-collapse") ?? {
2668
+ global: false,
2669
+ models: {},
2670
+ subBlocks: {}
2671
+ };
2672
+ } catch {
2673
+ return {
2674
+ global: false,
2675
+ models: {},
2676
+ subBlocks: {}
2677
+ };
2678
+ }
2679
+ }
2680
+ function saveCollapseState(api, state) {
2681
+ try {
2682
+ api.kv?.set?.("tokenwatch-collapse", state);
2683
+ } catch {
2684
+ }
2685
+ }
2686
+ function loadConfig(api) {
2687
+ const base = {
2688
+ sidebar: {
2689
+ ...DEFAULT_CONFIG2.sidebar
2690
+ },
2691
+ language: DEFAULT_CONFIG2.language
2692
+ };
2693
+ try {
2694
+ const pluginCfg = api.config?.pluginConfig?.["opencode-tokenwatch"];
2695
+ if (pluginCfg?.sidebar) Object.assign(base.sidebar, pluginCfg.sidebar);
2696
+ if (pluginCfg?.language) base.language = pluginCfg.language;
2697
+ const overrides = api.kv?.get?.("tokenwatch-config");
2698
+ if (overrides?.sidebar) Object.assign(base.sidebar, overrides.sidebar);
2699
+ if (overrides?.language) base.language = overrides.language;
2700
+ } catch {
2701
+ }
2702
+ return base;
2703
+ }
2704
+ function TokenWatchPanel(props) {
2705
+ const {
2706
+ api,
2707
+ theme,
2708
+ perfTracker
2709
+ } = props;
2710
+ const getMessages = () => props.messages();
2711
+ const [config, setConfig] = createSignal(loadConfig(api));
2712
+ setLanguage(config().language);
2713
+ let knownCfgVer = api.kv?.get?.("tokenwatch-config-version");
2714
+ const t2 = (key) => {
2715
+ void config().language;
2716
+ return t(key);
2717
+ };
2718
+ const isEnglish = (str) => /^[a-zA-Z\s\.\/]+$/.test(str);
2719
+ createEffect(() => {
2720
+ const timer = setInterval(() => {
2721
+ const v = api.kv?.get?.("tokenwatch-config-version");
2722
+ if (v != null && v !== knownCfgVer) {
2723
+ knownCfgVer = v;
2724
+ setConfig(loadConfig(api));
2725
+ }
2726
+ }, 500);
2727
+ onCleanup(() => clearInterval(timer));
2728
+ });
2729
+ const [collapse, setCollapse] = createSignal(loadCollapseState(api));
2730
+ const [panelWidth, setPanelWidth] = createSignal(38);
2731
+ let outerBoxRef = null;
2732
+ createEffect(() => setLanguage(config().language));
2733
+ const primaryColor = () => theme.current.primary;
2734
+ const mutedColor = () => theme.current.textMuted;
2735
+ const dimColor = () => RGBA.fromInts(72, 79, 88, 255);
2736
+ const greenColor = () => RGBA.fromInts(63, 185, 80, 255);
2737
+ const borderColor = () => RGBA.fromInts(55, 65, 80, 255);
2738
+ const modelStats = createMemo(() => {
2739
+ const map = /* @__PURE__ */ new Map();
2740
+ const msgs = props.allTokenMessages();
2741
+ for (let i = 0; i < msgs.length; i++) {
2742
+ const msg = msgs[i];
2743
+ const key = `${msg.providerID}/${msg.modelID}`;
2744
+ let e = map.get(key);
2745
+ if (!e) {
2746
+ e = {
2747
+ providerID: msg.providerID,
2748
+ modelID: msg.modelID,
2749
+ totalInput: 0,
2750
+ totalOutput: 0,
2751
+ totalReasoning: 0,
2752
+ cacheRead: 0,
2753
+ cacheWrite: 0,
2754
+ totalCost: 0,
2755
+ requestCount: 0,
2756
+ lastMessageIndex: -1
2757
+ };
2758
+ map.set(key, e);
2759
+ }
2760
+ e.totalInput += msg.inputTokens;
2761
+ e.totalOutput += msg.outputTokens;
2762
+ e.totalReasoning += msg.reasoningTokens;
2763
+ e.cacheRead += msg.cacheRead;
2764
+ e.cacheWrite += msg.cacheWrite;
2765
+ e.totalCost += msg.cost;
2766
+ e.requestCount++;
2767
+ e.lastMessageIndex = i;
2768
+ }
2769
+ return Array.from(map.entries()).filter(([, s]) => s.totalInput + s.totalOutput + s.totalReasoning + s.cacheRead + s.cacheWrite > 0).sort((a, b) => b[1].lastMessageIndex - a[1].lastMessageIndex);
2770
+ });
2771
+ const sessionTotals = createMemo(() => {
2772
+ let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
2773
+ for (const [, s] of modelStats()) {
2774
+ i += s.totalInput;
2775
+ o += s.totalOutput;
2776
+ ir += s.totalReasoning;
2777
+ cr += s.cacheRead;
2778
+ cw += s.cacheWrite;
2779
+ r += s.requestCount;
2780
+ c += s.totalCost;
2781
+ }
2782
+ return {
2783
+ totalInput: i,
2784
+ totalOutput: o,
2785
+ totalReasoning: ir,
2786
+ totalCacheRead: cr,
2787
+ totalCacheWrite: cw,
2788
+ totalRequests: r,
2789
+ totalCost: c,
2790
+ totalTokens: i + o + ir + cr + cw
2791
+ };
2792
+ });
2793
+ const globalHitRate = createMemo(() => {
2794
+ const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
2795
+ return denom > 0 ? sessionTotals().totalCacheRead / denom * 100 : -1;
2796
+ });
2797
+ const modelHitRate = createMemo(() => {
2798
+ return modelStats().map(([key, stat]) => {
2799
+ const denom = stat.totalInput + stat.cacheRead;
2800
+ if (denom === 0) return {
2801
+ key,
2802
+ rate: 0,
2803
+ msgs: []
2804
+ };
2805
+ const msgs = [];
2806
+ for (const msg of props.allTokenMessages()) {
2807
+ const pk = `${msg.providerID}/${msg.modelID}`;
2808
+ if (pk !== key) continue;
2809
+ msgs.push(msg);
2810
+ }
2811
+ return {
2812
+ key,
2813
+ rate: stat.cacheRead / denom * 100,
2814
+ msgs
2815
+ };
2816
+ });
2817
+ });
2818
+ const modelTrend = createMemo(() => {
2819
+ return modelHitRate().map(({
2820
+ key,
2821
+ msgs
2822
+ }) => {
2823
+ if (msgs.length < 6) return {
2824
+ key,
2825
+ trend: null
2826
+ };
2827
+ const sumSlice = (start, end) => {
2828
+ let sumCache = 0, sumTotal = 0;
2829
+ for (let i = start; i < end && i < msgs.length; i++) {
2830
+ sumCache += msgs[i].cacheRead;
2831
+ sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
2832
+ }
2833
+ return {
2834
+ sumCache,
2835
+ sumTotal
2836
+ };
2837
+ };
2838
+ const n = msgs.length;
2839
+ const recent = sumSlice(n - 3, n);
2840
+ const prev = sumSlice(n - 6, n - 3);
2841
+ const rateRecent = recent.sumTotal > 0 ? recent.sumCache / recent.sumTotal * 100 : 0;
2842
+ const ratePrev = prev.sumTotal > 0 ? prev.sumCache / prev.sumTotal * 100 : 0;
2843
+ return {
2844
+ key,
2845
+ trend: rateRecent - ratePrev
2846
+ };
2847
+ });
2848
+ });
2849
+ const [partVersion, setPartVersion] = createSignal(0);
2850
+ const perfStats = createMemo(() => {
2851
+ void props.allTokenMessages();
2852
+ void partVersion();
2853
+ return perfTracker.getSessionStats();
2854
+ });
2855
+ const tokenDistribution = createMemo(() => {
2856
+ void props.allTokenMessages();
2857
+ void partVersion();
2858
+ const dist = {};
2859
+ try {
2860
+ const cfg = api.state.config;
2861
+ const agents = cfg?.agent;
2862
+ if (agents) {
2863
+ for (const ac of Object.values(agents)) {
2864
+ const a = ac;
2865
+ if (typeof a?.prompt === "string" && a.prompt) {
2866
+ dist.system = estimateTokens(a.prompt);
2867
+ break;
2868
+ }
2869
+ }
2870
+ }
2871
+ } catch {
2872
+ }
2873
+ for (const msg of getMessages()) {
2874
+ const role = msg.role;
2875
+ if (role === "user") {
2876
+ if (msg.system) dist.system = (dist.system ?? 0) + estimateTokens(msg.system);
2877
+ let parts = [];
2878
+ try {
2879
+ parts = api.state.part(msg.id);
2880
+ } catch {
2881
+ continue;
2882
+ }
2883
+ for (const p of parts) {
2884
+ if (p.type === "text" && !p.synthetic && !p.ignored) {
2885
+ dist.user = (dist.user ?? 0) + estimateTokens(p.text ?? "");
2886
+ } else if (p.type === "file" && p.source?.text?.value) {
2887
+ dist.user = (dist.user ?? 0) + estimateTokens(p.source.text.value);
2888
+ }
2889
+ }
2890
+ } else if (role === "assistant") {
2891
+ let parts = [];
2892
+ try {
2893
+ parts = api.state.part(msg.id);
2894
+ } catch {
2895
+ continue;
2896
+ }
2897
+ let msgEstimatedOutput = 0;
2898
+ for (const p of parts) {
2899
+ if (p.type === "tool") {
2900
+ let rawInput = "";
2901
+ try {
2902
+ rawInput = p.state?.raw ?? JSON.stringify(p.state?.input);
2903
+ } catch {
2904
+ try {
2905
+ rawInput = JSON.stringify(p.state);
2906
+ } catch {
2907
+ }
2908
+ }
2909
+ if (rawInput) dist.toolCall = (dist.toolCall ?? 0) + estimateTokens(rawInput);
2910
+ if (p.state?.status === "completed" && p.state?.output) {
2911
+ dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.output);
2912
+ } else if (p.state?.status === "error" && p.state?.error) {
2913
+ dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
2914
+ }
2915
+ } else if (p.type === "text" && p.text) {
2916
+ msgEstimatedOutput += estimateTokens(p.text);
2917
+ } else if (p.type === "reasoning") {
2918
+ msgEstimatedOutput += estimateTokens(p.text ?? "");
2919
+ } else if (p.type === "subtask") {
2920
+ msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
2921
+ }
2922
+ }
2923
+ const tokens = msg.tokens;
2924
+ if (tokens?.output !== void 0 || tokens?.reasoning !== void 0) {
2925
+ dist.output = (dist.output ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
2926
+ } else {
2927
+ dist.output = (dist.output ?? 0) + msgEstimatedOutput;
2928
+ }
2929
+ }
2930
+ }
2931
+ const realInput = sessionTotals().totalInput;
2932
+ if (realInput > 0) {
2933
+ const estimated = (dist.system ?? 0) + (dist.user ?? 0) + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
2934
+ const other = realInput - estimated;
2935
+ if (other > 50) dist.other = other;
2936
+ }
2937
+ return dist;
2938
+ });
2939
+ const toggle = {
2940
+ global: () => setCollapse((p) => {
2941
+ const n = {
2942
+ ...p,
2943
+ global: !p.global
2944
+ };
2945
+ saveCollapseState(api, n);
2946
+ return n;
2947
+ }),
2948
+ model: (k) => setCollapse((p) => {
2949
+ const n = {
2950
+ ...p,
2951
+ models: {
2952
+ ...p.models,
2953
+ [k]: !p.models[k]
2954
+ }
2955
+ };
2956
+ saveCollapseState(api, n);
2957
+ return n;
2958
+ }),
2959
+ sub: (k) => setCollapse((p) => {
2960
+ const n = {
2961
+ ...p,
2962
+ subBlocks: {
2963
+ ...p.subBlocks,
2964
+ [k]: !p.subBlocks[k]
2965
+ }
2966
+ };
2967
+ saveCollapseState(api, n);
2968
+ return n;
2969
+ })
2970
+ };
2971
+ onMount(() => {
2972
+ const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion((v) => v + 1));
2973
+ onCleanup(() => {
2974
+ try {
2975
+ unsubPart?.();
2976
+ } catch {
2977
+ }
2978
+ });
2979
+ });
2980
+ const innerWidth = () => panelWidth() - 2;
2981
+ const barWidth = () => Math.max(8, innerWidth() - 19);
2982
+ const divider = () => {
2983
+ const w = innerWidth();
2984
+ if (w <= 2) return "\u2500".repeat(w);
2985
+ return " " + "\u2500".repeat(w - 2) + " ";
2986
+ };
2987
+ return (() => {
2988
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("box"), _el$3 = _$createElement("text"), _el$4 = _$createTextNode(` `), _el$5 = _$createElement("text");
2989
+ _$insertNode(_el$, _el$2);
2990
+ _$use((el) => {
2991
+ outerBoxRef = el;
2992
+ }, _el$);
2993
+ _$setProp(_el$, "onSizeChange", () => {
2994
+ if (outerBoxRef) setPanelWidth(outerBoxRef.width);
2995
+ });
2996
+ _$setProp(_el$, "flexDirection", "column");
2997
+ _$setProp(_el$, "border", true);
2998
+ _$setProp(_el$, "borderStyle", "rounded");
2999
+ _$insertNode(_el$2, _el$3);
3000
+ _$insertNode(_el$2, _el$5);
3001
+ _$setProp(_el$2, "flexDirection", "row");
3002
+ _$setProp(_el$2, "justifyContent", "space-between");
3003
+ _$setProp(_el$2, "paddingX", 1);
3004
+ _$insertNode(_el$3, _el$4);
3005
+ _$insert(_el$3, () => collapse().global ? "\u25B6" : "\u25BE", _el$4);
3006
+ _$insert(_el$3, () => t2("panelTitle"), null);
3007
+ _$insert(_el$5, (() => {
3008
+ var _c$ = _$memo(() => !!collapse().global);
3009
+ return () => _c$() ? [_$memo(() => formatTokens(sessionTotals().totalTokens)), _$memo(() => _$memo(() => globalHitRate() >= 0)() ? (() => {
3010
+ var _el$17 = _$createElement("span");
3011
+ _$insert(_el$17, () => ` (${globalHitRate().toFixed(1)}% hit)`);
3012
+ _$effect((_$p) => _$setProp(_el$17, "style", {
3013
+ fg: hitRateColor(globalHitRate())
3014
+ }, _$p));
3015
+ return _el$17;
3016
+ })() : "")] : _$memo(() => globalHitRate() >= 0)() ? (() => {
3017
+ var _el$18 = _$createElement("span");
3018
+ _$insert(_el$18, () => `${globalHitRate().toFixed(1)}% hit`);
3019
+ _$effect((_$p) => _$setProp(_el$18, "style", {
3020
+ fg: hitRateColor(globalHitRate())
3021
+ }, _$p));
3022
+ return _el$18;
3023
+ })() : "";
3024
+ })());
3025
+ _$insert(_el$, _$createComponent2(Show, {
3026
+ get when() {
3027
+ return !collapse().global;
3028
+ },
3029
+ get children() {
3030
+ return [(() => {
3031
+ var _el$6 = _$createElement("text");
3032
+ _$insert(_el$6, divider);
3033
+ _$effect((_$p) => _$setProp(_el$6, "fg", borderColor(), _$p));
3034
+ return _el$6;
3035
+ })(), (() => {
3036
+ var _el$7 = _$createElement("box");
3037
+ _$setProp(_el$7, "flexDirection", "row");
3038
+ _$setProp(_el$7, "paddingX", 1);
3039
+ _$insert(_el$7, _$createComponent2(For, {
3040
+ get each() {
3041
+ return [{
3042
+ val: formatTokens(sessionTotals().totalTokens),
3043
+ lbl: t2("total")
3044
+ }, {
3045
+ val: sessionTotals().totalRequests.toString(),
3046
+ lbl: t2("requests")
3047
+ }, {
3048
+ val: formatTokens(sessionTotals().totalInput),
3049
+ lbl: t2("input")
3050
+ }, {
3051
+ val: formatTokens(sessionTotals().totalOutput),
3052
+ lbl: t2("output")
3053
+ }];
3054
+ },
3055
+ children: (item, idx) => {
3056
+ const colW = () => {
3057
+ const totalW = panelWidth() - 4;
3058
+ const base = Math.floor(totalW / 4);
3059
+ return idx() === 3 ? totalW - base * 3 : base;
3060
+ };
3061
+ return (() => {
3062
+ var _el$19 = _$createElement("box"), _el$20 = _$createElement("text"), _el$21 = _$createElement("text");
3063
+ _$insertNode(_el$19, _el$20);
3064
+ _$insertNode(_el$19, _el$21);
3065
+ _$setProp(_el$19, "flexDirection", "column");
3066
+ _$insert(_el$20, () => centerAlign(item.val, colW()));
3067
+ _$insert(_el$21, () => centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW()));
3068
+ _$effect((_p$) => {
3069
+ var _v$9 = colW(), _v$0 = primaryColor(), _v$1 = dimColor();
3070
+ _v$9 !== _p$.e && (_p$.e = _$setProp(_el$19, "width", _v$9, _p$.e));
3071
+ _v$0 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$0, _p$.t));
3072
+ _v$1 !== _p$.a && (_p$.a = _$setProp(_el$21, "fg", _v$1, _p$.a));
3073
+ return _p$;
3074
+ }, {
3075
+ e: void 0,
3076
+ t: void 0,
3077
+ a: void 0
3078
+ });
3079
+ return _el$19;
3080
+ })();
3081
+ }
3082
+ }));
3083
+ return _el$7;
3084
+ })(), _$createComponent2(Show, {
3085
+ get when() {
3086
+ return _$memo(() => !!config().sidebar.showPricing)() && sessionTotals().totalCost > 0;
3087
+ },
3088
+ get children() {
3089
+ var _el$8 = _$createElement("box"), _el$9 = _$createElement("text"), _el$0 = _$createTextNode(`: `), _el$10 = _$createElement("span");
3090
+ _$insertNode(_el$8, _el$9);
3091
+ _$setProp(_el$8, "flexDirection", "row");
3092
+ _$setProp(_el$8, "justifyContent", "center");
3093
+ _$setProp(_el$8, "marginTop", 1);
3094
+ _$insertNode(_el$9, _el$0);
3095
+ _$insertNode(_el$9, _el$10);
3096
+ _$insert(_el$9, () => t2("cost"), _el$0);
3097
+ _$insert(_el$10, () => formatCost(sessionTotals().totalCost));
3098
+ _$effect((_p$) => {
3099
+ var _v$ = mutedColor(), _v$2 = {
3100
+ fg: greenColor()
3101
+ };
3102
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$9, "fg", _v$, _p$.e));
3103
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$10, "style", _v$2, _p$.t));
3104
+ return _p$;
3105
+ }, {
3106
+ e: void 0,
3107
+ t: void 0
3108
+ });
3109
+ return _el$8;
3110
+ }
3111
+ }), _$createComponent2(For, {
3112
+ get each() {
3113
+ return modelStats();
3114
+ },
3115
+ children: ([key, stat]) => {
3116
+ const isExpanded = () => collapse().models[key] !== true;
3117
+ const hitDenom = stat.totalInput + stat.cacheRead;
3118
+ const hitRate = hitDenom > 0 ? stat.cacheRead / hitDenom * 100 : 0;
3119
+ const trendStr = () => {
3120
+ if (!config().sidebar.showTrend) return "";
3121
+ const td = modelTrend().find((h) => h.key === key);
3122
+ if (!td?.trend || td.trend === 0) return "";
3123
+ return td.trend > 0 ? ` ${t2("trendUp")}${td.trend.toFixed(1)}%` : ` ${t2("trendDown")}${Math.abs(td.trend).toFixed(1)}%`;
3124
+ };
3125
+ const trendColor = () => {
3126
+ const td = modelTrend().find((h) => h.key === key);
3127
+ return (td?.trend ?? 0) >= 0 ? RGBA.fromInts(63, 185, 80, 255) : RGBA.fromInts(244, 67, 54, 255);
3128
+ };
3129
+ const MAX_PROVIDER_LEN = 12;
3130
+ let providerDisplay = stat.providerID;
3131
+ if (providerDisplay.length > MAX_PROVIDER_LEN) {
3132
+ providerDisplay = providerDisplay.slice(0, MAX_PROVIDER_LEN - 1) + "\u2026";
3133
+ }
3134
+ let fullTitle = `${providerDisplay}/${stat.modelID}`;
3135
+ if (fullTitle.length > 22) {
3136
+ const parts = fullTitle.split("/");
3137
+ if (parts.length >= 3) {
3138
+ fullTitle = `${parts[0]}/${parts[parts.length - 1]}`;
3139
+ }
3140
+ }
3141
+ const maxNameLen = Math.max(8, innerWidth() - 12);
3142
+ const shortTitle = fullTitle.length > maxNameLen ? fullTitle.slice(0, maxNameLen - 1) + "\u2026" : fullTitle;
3143
+ const modelHeaderRight = () => {
3144
+ if (!isExpanded()) {
3145
+ const total = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
3146
+ return `${formatTokens(total)} \u25B6`;
3147
+ }
3148
+ return `\xD7${stat.requestCount} \u25BE`;
3149
+ };
3150
+ const modelTotalTokens = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
3151
+ const targetW = () => {
3152
+ const cacheLabel = t2("cache") + ":";
3153
+ const costLabel = t2("cost") + ":";
3154
+ return Math.max(getVisualWidth2(cacheLabel), getVisualWidth2(costLabel));
3155
+ };
3156
+ const paddedCachePrefix = () => {
3157
+ const label = t2("cache") + ":";
3158
+ return label + " ".repeat(targetW() - getVisualWidth2(label));
3159
+ };
3160
+ const paddedCostPrefix = () => {
3161
+ const label = t2("cost") + ":";
3162
+ return label + " ".repeat(targetW() - getVisualWidth2(label));
3163
+ };
3164
+ const modelBarWidth = () => Math.max(8, panelWidth() - 4 - targetW() - 11);
3165
+ return (
3166
+ // marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
3167
+ (() => {
3168
+ var _el$22 = _$createElement("box"), _el$23 = _$createElement("box"), _el$24 = _$createElement("text"), _el$25 = _$createElement("span"), _el$27 = _$createTextNode(` `), _el$28 = _$createElement("span"), _el$29 = _$createElement("text");
3169
+ _$insertNode(_el$22, _el$23);
3170
+ _$setProp(_el$22, "flexDirection", "column");
3171
+ _$setProp(_el$22, "marginTop", 1);
3172
+ _$insertNode(_el$23, _el$24);
3173
+ _$insertNode(_el$23, _el$29);
3174
+ _$setProp(_el$23, "flexDirection", "row");
3175
+ _$setProp(_el$23, "justifyContent", "space-between");
3176
+ _$setProp(_el$23, "onMouseDown", () => toggle.model(key));
3177
+ _$setProp(_el$23, "paddingX", 1);
3178
+ _$insertNode(_el$24, _el$25);
3179
+ _$insertNode(_el$24, _el$27);
3180
+ _$insertNode(_el$24, _el$28);
3181
+ _$insertNode(_el$25, _$createTextNode(`\u25CF`));
3182
+ _$insert(_el$28, shortTitle);
3183
+ _$insert(_el$29, modelHeaderRight);
3184
+ _$insert(_el$22, _$createComponent2(Show, {
3185
+ get when() {
3186
+ return isExpanded();
3187
+ },
3188
+ get children() {
3189
+ var _el$30 = _$createElement("box"), _el$31 = _$createElement("box"), _el$32 = _$createElement("box"), _el$33 = _$createElement("text"), _el$34 = _$createElement("span"), _el$35 = _$createTextNode(` `), _el$36 = _$createTextNode(`%`);
3190
+ _$insertNode(_el$30, _el$31);
3191
+ _$insertNode(_el$30, _el$33);
3192
+ _$setProp(_el$30, "flexDirection", "column");
3193
+ _$setProp(_el$30, "paddingX", 1);
3194
+ _$insertNode(_el$31, _el$32);
3195
+ _$setProp(_el$31, "flexDirection", "column");
3196
+ _$setProp(_el$31, "border", true);
3197
+ _$setProp(_el$31, "borderStyle", "rounded");
3198
+ _$setProp(_el$32, "flexDirection", "row");
3199
+ _$insert(_el$32, _$createComponent2(For, {
3200
+ get each() {
3201
+ return [{
3202
+ val: formatTokens(modelTotalTokens),
3203
+ lbl: t2("total")
3204
+ }, {
3205
+ val: formatTokens(stat.totalInput),
3206
+ lbl: t2("input")
3207
+ }, {
3208
+ val: formatTokens(stat.totalOutput),
3209
+ lbl: t2("output")
3210
+ }];
3211
+ },
3212
+ children: (item, idx) => {
3213
+ const colW = () => {
3214
+ const totalW = panelWidth() - 6;
3215
+ const base = Math.floor(totalW / 3);
3216
+ return idx() === 2 ? totalW - base * 2 : base;
3217
+ };
3218
+ return (() => {
3219
+ var _el$47 = _$createElement("box"), _el$48 = _$createElement("text"), _el$49 = _$createElement("text");
3220
+ _$insertNode(_el$47, _el$48);
3221
+ _$insertNode(_el$47, _el$49);
3222
+ _$setProp(_el$47, "flexDirection", "column");
3223
+ _$insert(_el$48, () => centerAlign(item.val, colW()));
3224
+ _$insert(_el$49, () => centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW()));
3225
+ _$effect((_p$) => {
3226
+ var _v$21 = colW(), _v$22 = primaryColor(), _v$23 = dimColor();
3227
+ _v$21 !== _p$.e && (_p$.e = _$setProp(_el$47, "width", _v$21, _p$.e));
3228
+ _v$22 !== _p$.t && (_p$.t = _$setProp(_el$48, "fg", _v$22, _p$.t));
3229
+ _v$23 !== _p$.a && (_p$.a = _$setProp(_el$49, "fg", _v$23, _p$.a));
3230
+ return _p$;
3231
+ }, {
3232
+ e: void 0,
3233
+ t: void 0,
3234
+ a: void 0
3235
+ });
3236
+ return _el$47;
3237
+ })();
3238
+ }
3239
+ }));
3240
+ _$insertNode(_el$33, _el$34);
3241
+ _$insert(_el$33, paddedCachePrefix, _el$34);
3242
+ _$insertNode(_el$34, _el$35);
3243
+ _$insertNode(_el$34, _el$36);
3244
+ _$insert(_el$34, () => progressFilled(hitRate, modelBarWidth()), _el$35);
3245
+ _$insert(_el$34, () => progressRemaining(hitRate, modelBarWidth()), _el$35);
3246
+ _$insert(_el$34, () => hitRate.toFixed(0), _el$36);
3247
+ _$insert(_el$33, (() => {
3248
+ var _c$2 = _$memo(() => !!trendStr());
3249
+ return () => _c$2() ? (() => {
3250
+ var _el$50 = _$createElement("span");
3251
+ _$insert(_el$50, trendStr);
3252
+ _$effect((_$p) => _$setProp(_el$50, "style", {
3253
+ fg: trendColor()
3254
+ }, _$p));
3255
+ return _el$50;
3256
+ })() : null;
3257
+ })(), null);
3258
+ _$insert(_el$30, _$createComponent2(Show, {
3259
+ get when() {
3260
+ return _$memo(() => !!config().sidebar.showPerformance)() && !!perfStats().models[key];
3261
+ },
3262
+ get children() {
3263
+ var _el$37 = _$createElement("text"), _el$38 = _$createTextNode(` `), _el$39 = _$createElement("span"), _el$40 = _$createTextNode(` `), _el$41 = _$createTextNode(` `), _el$42 = _$createElement("span"), _el$43 = _$createTextNode(` `), _el$44 = _$createTextNode(` `), _el$45 = _$createElement("span");
3264
+ _$insertNode(_el$37, _el$38);
3265
+ _$insertNode(_el$37, _el$39);
3266
+ _$insertNode(_el$37, _el$40);
3267
+ _$insertNode(_el$37, _el$41);
3268
+ _$insertNode(_el$37, _el$42);
3269
+ _$insertNode(_el$37, _el$43);
3270
+ _$insertNode(_el$37, _el$44);
3271
+ _$insertNode(_el$37, _el$45);
3272
+ _$setProp(_el$37, "marginTop", 1);
3273
+ _$insert(_el$37, () => t2("ttft"), _el$38);
3274
+ _$insert(_el$39, () => formatDuration(perfStats().models[key]?.avgTTFT ?? null));
3275
+ _$insert(_el$37, () => t2("tps"), _el$41);
3276
+ _$insert(_el$42, () => perfStats().models[key]?.avgTPS?.toFixed(1) ?? "\u2014");
3277
+ _$insert(_el$37, () => t2("lat"), _el$44);
3278
+ _$insert(_el$45, () => formatDuration(perfStats().models[key]?.avgLatency ?? null));
3279
+ _$effect((_p$) => {
3280
+ var _v$10 = mutedColor(), _v$11 = {
3281
+ fg: primaryColor()
3282
+ }, _v$12 = {
3283
+ fg: primaryColor()
3284
+ }, _v$13 = {
3285
+ fg: primaryColor()
3286
+ };
3287
+ _v$10 !== _p$.e && (_p$.e = _$setProp(_el$37, "fg", _v$10, _p$.e));
3288
+ _v$11 !== _p$.t && (_p$.t = _$setProp(_el$39, "style", _v$11, _p$.t));
3289
+ _v$12 !== _p$.a && (_p$.a = _$setProp(_el$42, "style", _v$12, _p$.a));
3290
+ _v$13 !== _p$.o && (_p$.o = _$setProp(_el$45, "style", _v$13, _p$.o));
3291
+ return _p$;
3292
+ }, {
3293
+ e: void 0,
3294
+ t: void 0,
3295
+ a: void 0,
3296
+ o: void 0
3297
+ });
3298
+ return _el$37;
3299
+ }
3300
+ }), null);
3301
+ _$insert(_el$30, _$createComponent2(Show, {
3302
+ get when() {
3303
+ return _$memo(() => !!config().sidebar.showPricing)() && stat.totalCost > 0;
3304
+ },
3305
+ get children() {
3306
+ var _el$46 = _$createElement("text");
3307
+ _$insert(_el$46, paddedCostPrefix, null);
3308
+ _$insert(_el$46, () => formatCost(stat.totalCost), null);
3309
+ _$effect((_$p) => _$setProp(_el$46, "fg", mutedColor(), _$p));
3310
+ return _el$46;
3311
+ }
3312
+ }), null);
3313
+ _$effect((_p$) => {
3314
+ var _v$14 = borderColor(), _v$15 = mutedColor(), _v$16 = {
3315
+ fg: hitRateColor(hitRate)
3316
+ };
3317
+ _v$14 !== _p$.e && (_p$.e = _$setProp(_el$31, "borderColor", _v$14, _p$.e));
3318
+ _v$15 !== _p$.t && (_p$.t = _$setProp(_el$33, "fg", _v$15, _p$.t));
3319
+ _v$16 !== _p$.a && (_p$.a = _$setProp(_el$34, "style", _v$16, _p$.a));
3320
+ return _p$;
3321
+ }, {
3322
+ e: void 0,
3323
+ t: void 0,
3324
+ a: void 0
3325
+ });
3326
+ return _el$30;
3327
+ }
3328
+ }), null);
3329
+ _$effect((_p$) => {
3330
+ var _v$17 = mutedColor(), _v$18 = {
3331
+ fg: hitRateColor(hitRate)
3332
+ }, _v$19 = {
3333
+ fg: primaryColor()
3334
+ }, _v$20 = mutedColor();
3335
+ _v$17 !== _p$.e && (_p$.e = _$setProp(_el$24, "fg", _v$17, _p$.e));
3336
+ _v$18 !== _p$.t && (_p$.t = _$setProp(_el$25, "style", _v$18, _p$.t));
3337
+ _v$19 !== _p$.a && (_p$.a = _$setProp(_el$28, "style", _v$19, _p$.a));
3338
+ _v$20 !== _p$.o && (_p$.o = _$setProp(_el$29, "fg", _v$20, _p$.o));
3339
+ return _p$;
3340
+ }, {
3341
+ e: void 0,
3342
+ t: void 0,
3343
+ a: void 0,
3344
+ o: void 0
3345
+ });
3346
+ return _el$22;
3347
+ })()
3348
+ );
3349
+ }
3350
+ }), _$createComponent2(Show, {
3351
+ get when() {
3352
+ return config().sidebar.showTokenDistribution;
3353
+ },
3354
+ get children() {
3355
+ var _el$11 = _$createElement("box"), _el$12 = _$createElement("text"), _el$13 = _$createElement("box"), _el$14 = _$createElement("text"), _el$15 = _$createTextNode(` `);
3356
+ _$insertNode(_el$11, _el$12);
3357
+ _$insertNode(_el$11, _el$13);
3358
+ _$setProp(_el$11, "flexDirection", "column");
3359
+ _$setProp(_el$11, "marginTop", 1);
3360
+ _$insert(_el$12, divider);
3361
+ _$insertNode(_el$13, _el$14);
3362
+ _$setProp(_el$13, "flexDirection", "row");
3363
+ _$setProp(_el$13, "onMouseDown", () => toggle.sub("token-dist"));
3364
+ _$setProp(_el$13, "paddingX", 1);
3365
+ _$insertNode(_el$14, _el$15);
3366
+ _$insert(_el$14, () => !collapse().subBlocks["token-dist"] ? "\u25BE" : "\u25B6", _el$15);
3367
+ _$insert(_el$14, () => t2("tokenDistribution"), null);
3368
+ _$insert(_el$11, _$createComponent2(Show, {
3369
+ get when() {
3370
+ return !collapse().subBlocks["token-dist"];
3371
+ },
3372
+ get children() {
3373
+ var _el$16 = _$createElement("box");
3374
+ _$setProp(_el$16, "flexDirection", "column");
3375
+ _$setProp(_el$16, "paddingX", 1);
3376
+ _$setProp(_el$16, "marginTop", 1);
3377
+ _$insert(_el$16, _$createComponent2(For, {
3378
+ get each() {
3379
+ return Object.entries(tokenDistribution()).filter(([_, val]) => val > 0);
3380
+ },
3381
+ children: ([role, val]) => (() => {
3382
+ var _el$51 = _$createElement("box"), _el$52 = _$createElement("box"), _el$53 = _$createElement("text"), _el$55 = _$createElement("text"), _el$56 = _$createElement("text");
3383
+ _$insertNode(_el$51, _el$52);
3384
+ _$insertNode(_el$51, _el$56);
3385
+ _$setProp(_el$51, "flexDirection", "row");
3386
+ _$setProp(_el$51, "justifyContent", "space-between");
3387
+ _$insertNode(_el$52, _el$53);
3388
+ _$insertNode(_el$52, _el$55);
3389
+ _$setProp(_el$52, "flexDirection", "row");
3390
+ _$insertNode(_el$53, _$createTextNode(`\u2588 `));
3391
+ _$insert(_el$55, () => t2(role));
3392
+ _$insert(_el$56, () => formatTokens(val));
3393
+ _$effect((_p$) => {
3394
+ var _v$24 = distRoleColor(role), _v$25 = mutedColor(), _v$26 = mutedColor();
3395
+ _v$24 !== _p$.e && (_p$.e = _$setProp(_el$53, "fg", _v$24, _p$.e));
3396
+ _v$25 !== _p$.t && (_p$.t = _$setProp(_el$55, "fg", _v$25, _p$.t));
3397
+ _v$26 !== _p$.a && (_p$.a = _$setProp(_el$56, "fg", _v$26, _p$.a));
3398
+ return _p$;
3399
+ }, {
3400
+ e: void 0,
3401
+ t: void 0,
3402
+ a: void 0
3403
+ });
3404
+ return _el$51;
3405
+ })()
3406
+ }));
3407
+ return _el$16;
3408
+ }
3409
+ }), null);
3410
+ _$effect((_p$) => {
3411
+ var _v$3 = borderColor(), _v$4 = greenColor();
3412
+ _v$3 !== _p$.e && (_p$.e = _$setProp(_el$12, "fg", _v$3, _p$.e));
3413
+ _v$4 !== _p$.t && (_p$.t = _$setProp(_el$14, "fg", _v$4, _p$.t));
3414
+ return _p$;
3415
+ }, {
3416
+ e: void 0,
3417
+ t: void 0
3418
+ });
3419
+ return _el$11;
3420
+ }
3421
+ })];
3422
+ }
3423
+ }), null);
3424
+ _$effect((_p$) => {
3425
+ var _v$5 = borderColor(), _v$6 = toggle.global, _v$7 = primaryColor(), _v$8 = mutedColor();
3426
+ _v$5 !== _p$.e && (_p$.e = _$setProp(_el$, "borderColor", _v$5, _p$.e));
3427
+ _v$6 !== _p$.t && (_p$.t = _$setProp(_el$2, "onMouseDown", _v$6, _p$.t));
3428
+ _v$7 !== _p$.a && (_p$.a = _$setProp(_el$3, "fg", _v$7, _p$.a));
3429
+ _v$8 !== _p$.o && (_p$.o = _$setProp(_el$5, "fg", _v$8, _p$.o));
3430
+ return _p$;
3431
+ }, {
3432
+ e: void 0,
3433
+ t: void 0,
3434
+ a: void 0,
3435
+ o: void 0
3436
+ });
3437
+ return _el$;
3438
+ })();
3439
+ }
3440
+
3441
+ // src/tui.tsx
3442
+ function kvKey(sessionID) {
3443
+ return "tokenwatch-msgs-" + sessionID;
3444
+ }
3445
+ var tui = async (api) => {
3446
+ const perfTracker = createPerfTracker();
3447
+ const [sidebarRevision, setSidebarRevision] = createSignal2(0);
3448
+ const [allTokenMessages, setAllTokenMessages] = createSignal2([]);
3449
+ let currentSlotSessionID = "";
3450
+ const cleanups = [];
3451
+ registerCommands(api);
3452
+ function persistToKv(sessionID, msgs) {
3453
+ try {
3454
+ api.kv?.set?.(kvKey(sessionID), msgs);
3455
+ } catch {
3456
+ }
3457
+ }
3458
+ const unsubMsgUpdated = api.event.on("message.updated", (event) => {
3459
+ const info = event.properties?.info;
3460
+ perfTracker.handleMessageUpdated(event);
3461
+ if (info?.role === "assistant" && info?.tokens?.total > 0) {
3462
+ setAllTokenMessages((prev) => {
3463
+ const msg = {
3464
+ id: info.id,
3465
+ sessionID: info.sessionID ?? "",
3466
+ providerID: info.providerID ?? "unknown",
3467
+ modelID: info.modelID ?? "unknown",
3468
+ inputTokens: info.tokens?.input ?? 0,
3469
+ outputTokens: info.tokens?.output ?? 0,
3470
+ reasoningTokens: info.tokens?.reasoning ?? 0,
3471
+ cacheRead: info.tokens?.cache?.read ?? 0,
3472
+ cacheWrite: info.tokens?.cache?.write ?? 0,
3473
+ cost: info.cost ?? 0
3474
+ };
3475
+ const idx = prev.findIndex((m) => m.id === msg.id);
3476
+ let next;
3477
+ if (idx >= 0) {
3478
+ next = [...prev];
3479
+ next[idx] = msg;
3480
+ } else {
3481
+ next = [...prev, msg];
3482
+ }
3483
+ const targetSessionID = info.sessionID ?? currentSlotSessionID;
3484
+ persistToKv(targetSessionID, next);
3485
+ return next;
3486
+ });
3487
+ }
3488
+ setSidebarRevision((v) => v + 1);
3489
+ });
3490
+ cleanups.push(unsubMsgUpdated);
3491
+ const unsubPartUpdated = api.event.on("message.part.updated", (event) => {
3492
+ perfTracker.handlePartUpdated({
3493
+ message_id: event.properties?.part?.messageID,
3494
+ type: event.properties?.part?.type,
3495
+ text: event.properties?.part?.type === "text" ? event.properties?.part?.text : void 0,
3496
+ time: {
3497
+ start: event.properties?.part?.time?.start
3498
+ }
3499
+ });
3500
+ });
3501
+ cleanups.push(unsubPartUpdated);
3502
+ const unsubRemoved = api.event.on("message.removed", () => {
3503
+ setSidebarRevision((v) => v + 1);
3504
+ });
3505
+ cleanups.push(unsubRemoved);
3506
+ api.lifecycle?.onDispose?.(() => {
3507
+ for (const cleanup of cleanups) cleanup();
3508
+ });
3509
+ api.slots.register({
3510
+ order: 50,
3511
+ slots: {
3512
+ sidebar_content: (_ctx, {
3513
+ session_id
3514
+ }) => {
3515
+ sidebarRevision();
3516
+ if (session_id && session_id !== currentSlotSessionID) {
3517
+ currentSlotSessionID = session_id;
3518
+ perfTracker.loadSession(session_id);
3519
+ let loaded = [];
3520
+ try {
3521
+ const saved = api.kv?.get?.(kvKey(session_id));
3522
+ if (saved && saved.length > 0) loaded = saved;
3523
+ } catch {
3524
+ }
3525
+ setAllTokenMessages(loaded);
3526
+ }
3527
+ createEffect2(() => {
3528
+ if (!session_id) return;
3529
+ let timer = null;
3530
+ let pollCount = 0;
3531
+ const maxPolls = 50;
3532
+ const checkAndPopulate = () => {
3533
+ const existing = api.state.session.messages(session_id);
3534
+ if (!existing || existing.length === 0) return false;
3535
+ setAllTokenMessages((prev) => {
3536
+ let changed = false;
3537
+ const next = [...prev];
3538
+ for (const msg of existing) {
3539
+ if (msg.role !== "assistant") continue;
3540
+ const tokens = msg.tokens;
3541
+ if (!tokens || (tokens.total ?? 0) === 0) continue;
3542
+ const id = msg.id;
3543
+ const idx = next.findIndex((m) => m.id === id);
3544
+ const tokenMsg = {
3545
+ id,
3546
+ sessionID: session_id,
3547
+ providerID: msg.providerID ?? "unknown",
3548
+ modelID: msg.modelID ?? "unknown",
3549
+ inputTokens: tokens?.input ?? 0,
3550
+ outputTokens: tokens?.output ?? 0,
3551
+ reasoningTokens: tokens?.reasoning ?? 0,
3552
+ cacheRead: tokens?.cache?.read ?? 0,
3553
+ cacheWrite: tokens?.cache?.write ?? 0,
3554
+ cost: msg.cost ?? 0
3555
+ };
3556
+ if (idx >= 0) {
3557
+ const cur = next[idx];
3558
+ if (cur.inputTokens !== tokenMsg.inputTokens || cur.outputTokens !== tokenMsg.outputTokens || cur.reasoningTokens !== tokenMsg.reasoningTokens || cur.cacheRead !== tokenMsg.cacheRead || cur.cacheWrite !== tokenMsg.cacheWrite || cur.cost !== tokenMsg.cost) {
3559
+ next[idx] = tokenMsg;
3560
+ changed = true;
3561
+ }
3562
+ } else {
3563
+ next.push(tokenMsg);
3564
+ changed = true;
3565
+ }
3566
+ }
3567
+ if (changed) {
3568
+ persistToKv(session_id, next);
3569
+ return next;
3570
+ }
3571
+ return prev;
3572
+ });
3573
+ return true;
3574
+ };
3575
+ const hasMessages = checkAndPopulate();
3576
+ if (!hasMessages) {
3577
+ timer = setInterval(() => {
3578
+ pollCount++;
3579
+ if (checkAndPopulate() || pollCount >= maxPolls) {
3580
+ clearInterval(timer);
3581
+ timer = null;
3582
+ }
3583
+ }, 200);
3584
+ }
3585
+ onCleanup2(() => {
3586
+ if (timer) {
3587
+ clearInterval(timer);
3588
+ }
3589
+ });
3590
+ });
3591
+ return _$createComponent3(TokenWatchPanel, {
3592
+ api,
3593
+ get theme() {
3594
+ return api.theme;
3595
+ },
3596
+ perfTracker,
3597
+ messages: () => api.state.session.messages(session_id),
3598
+ allTokenMessages
3599
+ });
3600
+ }
3601
+ }
3602
+ });
3603
+ };
3604
+ var plugin = {
3605
+ id: "opencode-tokenwatch",
3606
+ tui
3607
+ };
3608
+ var tui_default = plugin;
3609
+ export {
3610
+ tui_default as default
3611
+ };