opencode-tokenwatch 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +97 -96
- package/README.md +97 -96
- package/dist/commands.jsx +58 -2
- package/dist/formatter.d.ts +30 -0
- package/dist/generate-usage-html.js +749 -622
- package/dist/i18n.js +0 -4
- package/dist/perf-tracker.d.ts +5 -0
- package/dist/perf-tracker.js +85 -13
- package/dist/queries.d.ts +3 -1
- package/dist/queries.js +156 -97
- package/dist/sidebar.jsx +127 -94
- package/dist/tui.jsx +4 -1
- package/package.json +63 -63
- package/dist/commands.js +0 -208
package/dist/sidebar.jsx
CHANGED
|
@@ -101,11 +101,13 @@ export function TokenWatchPanel(props) {
|
|
|
101
101
|
const key = `${msg.providerID}/${msg.modelID}`;
|
|
102
102
|
let e = map.get(key);
|
|
103
103
|
if (!e) {
|
|
104
|
-
|
|
104
|
+
// Bug fix: 初始化时加入 totalReasoning 字段
|
|
105
|
+
e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
|
|
105
106
|
map.set(key, e);
|
|
106
107
|
}
|
|
107
108
|
e.totalInput += msg.inputTokens;
|
|
108
109
|
e.totalOutput += msg.outputTokens;
|
|
110
|
+
e.totalReasoning += msg.reasoningTokens; // Bug fix: 聚合 reasoning
|
|
109
111
|
e.cacheRead += msg.cacheRead;
|
|
110
112
|
e.cacheWrite += msg.cacheWrite;
|
|
111
113
|
e.totalCost += msg.cost;
|
|
@@ -114,16 +116,18 @@ export function TokenWatchPanel(props) {
|
|
|
114
116
|
return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
|
|
115
117
|
});
|
|
116
118
|
const sessionTotals = createMemo(() => {
|
|
117
|
-
let i = 0, o = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
119
|
+
let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
|
|
118
120
|
for (const [, s] of modelStats()) {
|
|
119
121
|
i += s.totalInput;
|
|
120
122
|
o += s.totalOutput;
|
|
123
|
+
ir += s.totalReasoning;
|
|
121
124
|
cr += s.cacheRead;
|
|
122
125
|
cw += s.cacheWrite;
|
|
123
126
|
r += s.requestCount;
|
|
124
127
|
c += s.totalCost;
|
|
125
128
|
}
|
|
126
|
-
|
|
129
|
+
// Bug fix: totalTokens 改为 5 分量(含 reasoning),与官方一致
|
|
130
|
+
return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
|
|
127
131
|
});
|
|
128
132
|
const modelHitRate = createMemo(() => {
|
|
129
133
|
return modelStats().map(([key, stat]) => {
|
|
@@ -230,6 +234,11 @@ export function TokenWatchPanel(props) {
|
|
|
230
234
|
dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
|
|
231
235
|
}
|
|
232
236
|
}
|
|
237
|
+
else if (p.type === "text" && p.text) {
|
|
238
|
+
// Bug fix: assistant text part(主要 LLM 回复内容)之前未计入分布
|
|
239
|
+
// 此处用 estimateTokens 估算,最终 dist.output 会被下方真实 tokens.output 覆盖
|
|
240
|
+
dist.output = (dist.output ?? 0) + estimateTokens(p.text);
|
|
241
|
+
}
|
|
233
242
|
else if (p.type === "reasoning") {
|
|
234
243
|
dist.agent = (dist.agent ?? 0) + estimateTokens(p.text ?? "");
|
|
235
244
|
}
|
|
@@ -238,10 +247,22 @@ export function TokenWatchPanel(props) {
|
|
|
238
247
|
}
|
|
239
248
|
}
|
|
240
249
|
const tokens = msg.tokens;
|
|
250
|
+
// 真实 output token 数优先:覆盖上面的 text part 估算
|
|
241
251
|
if (tokens?.output)
|
|
242
252
|
dist.output = (dist.output ?? 0) + tokens.output;
|
|
243
253
|
}
|
|
244
254
|
}
|
|
255
|
+
// Bug fix: 添加 other 兜底桶(参考官方 session-context-breakdown.ts)
|
|
256
|
+
// 使用真实 input token 数减去各桶估算值,确保分布总和对齐
|
|
257
|
+
const realInput = sessionTotals().totalInput;
|
|
258
|
+
if (realInput > 0) {
|
|
259
|
+
const estimated = (dist.system ?? 0) + (dist.user ?? 0)
|
|
260
|
+
+ (dist.agent ?? 0) + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
|
|
261
|
+
const other = realInput - estimated;
|
|
262
|
+
// 只在差值超过 50 token 时展示,避免估算误差噪音
|
|
263
|
+
if (other > 50)
|
|
264
|
+
dist.other = other;
|
|
265
|
+
}
|
|
245
266
|
return dist;
|
|
246
267
|
});
|
|
247
268
|
const toggle = {
|
|
@@ -250,36 +271,47 @@ export function TokenWatchPanel(props) {
|
|
|
250
271
|
sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
|
|
251
272
|
};
|
|
252
273
|
onMount(() => {
|
|
274
|
+
// Risk fix: 移除 message.updated 的重复订阅。
|
|
275
|
+
// tui.tsx 已通过 setSidebarRevision() 驱动 sidebar 整体重渲,
|
|
276
|
+
// sidebar 内部只需订阅 part.updated 来触发 tokenDistribution 重算。
|
|
253
277
|
const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
|
|
254
|
-
const unsubMsg = api.event?.on?.("message.updated", () => setPartVersion(v => v + 1));
|
|
255
278
|
onCleanup(() => { try {
|
|
256
279
|
unsubPart?.();
|
|
257
|
-
unsubMsg?.();
|
|
258
280
|
}
|
|
259
281
|
catch { } });
|
|
260
282
|
});
|
|
261
|
-
return (<box flexDirection="column" width={panelWidth()}>
|
|
262
|
-
<box onMouseDown={toggle.global}>
|
|
263
|
-
<text fg={primaryColor()}>
|
|
264
|
-
{collapse().global ? "▶" : "▼"} {t("panelTitle")}
|
|
265
|
-
{collapse().global ? ` ${t("cacheRead")}:${formatTokens(sessionTotals().totalCacheRead)} ${t("requests")}:${sessionTotals().totalRequests}` : ""}
|
|
266
|
-
</text>
|
|
267
|
-
</box>
|
|
268
|
-
|
|
269
|
-
<Show when={!collapse().global}>
|
|
270
|
-
<text fg={mutedColor()}>
|
|
271
|
-
{t("total")}:{formatTokens(sessionTotals().totalTokens)} {t("requests")}:{sessionTotals().totalRequests}
|
|
272
|
-
</text>
|
|
273
|
-
<text fg={mutedColor()}>
|
|
274
|
-
{t("input")}:{formatTokens(sessionTotals().totalInput)} {t("output")}:{formatTokens(sessionTotals().totalOutput)} {t("cacheRead")}:{formatTokens(sessionTotals().totalCacheRead)}
|
|
275
|
-
</text>
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
+
return (<box flexDirection="column" width={panelWidth()}>
|
|
284
|
+
<box onMouseDown={toggle.global}>
|
|
285
|
+
<text fg={primaryColor()}>
|
|
286
|
+
{collapse().global ? "▶" : "▼"} {t("panelTitle")}
|
|
287
|
+
{collapse().global ? ` ${t("cacheRead")}:${formatTokens(sessionTotals().totalCacheRead)} ${t("requests")}:${sessionTotals().totalRequests}` : ""}
|
|
288
|
+
</text>
|
|
289
|
+
</box>
|
|
290
|
+
|
|
291
|
+
<Show when={!collapse().global}>
|
|
292
|
+
<text fg={mutedColor()}>
|
|
293
|
+
{t("total")}:{formatTokens(sessionTotals().totalTokens)} {t("requests")}:{sessionTotals().totalRequests}
|
|
294
|
+
</text>
|
|
295
|
+
<text fg={mutedColor()}>
|
|
296
|
+
{t("input")}:{formatTokens(sessionTotals().totalInput)} {t("output")}:{formatTokens(sessionTotals().totalOutput)} {t("cacheRead")}:{formatTokens(sessionTotals().totalCacheRead)}
|
|
297
|
+
</text>
|
|
298
|
+
{/* 全局加权缓存命中率(按 token 加权:totalCacheRead / (totalCacheRead + totalInput)) */}
|
|
299
|
+
<Show when={(sessionTotals().totalInput + sessionTotals().totalCacheRead) > 0}>
|
|
300
|
+
<text fg={mutedColor()}>
|
|
301
|
+
{t("cache")}Hit:{(() => {
|
|
302
|
+
const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
|
|
303
|
+
const rate = denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : 0;
|
|
304
|
+
return rate.toFixed(1) + "%";
|
|
305
|
+
})()}(global)
|
|
306
|
+
</text>
|
|
307
|
+
</Show>
|
|
308
|
+
<Show when={sessionTotals().totalCost > 0}>
|
|
309
|
+
<text fg={mutedColor()}>
|
|
310
|
+
{t("cost")}:{formatCost(sessionTotals().totalCost)}
|
|
311
|
+
</text>
|
|
312
|
+
</Show>
|
|
313
|
+
|
|
314
|
+
<For each={modelStats()}>
|
|
283
315
|
{([key, stat]) => {
|
|
284
316
|
const modelCollapsed = () => collapse().models[key] !== true;
|
|
285
317
|
const totalInput = stat.totalInput + stat.cacheRead;
|
|
@@ -290,73 +322,74 @@ export function TokenWatchPanel(props) {
|
|
|
290
322
|
: "";
|
|
291
323
|
const title = `${stat.providerID}/${stat.modelID}`;
|
|
292
324
|
const shortTitle = title.length > 32 ? title.slice(0, 30) + "…" : title;
|
|
293
|
-
return (<box flexDirection="column">
|
|
294
|
-
<box onMouseDown={() => toggle.model(key)}>
|
|
295
|
-
<text fg={primaryColor()}>{modelCollapsed() ? "▼" : "▶"} {shortTitle}</text>
|
|
296
|
-
</box>
|
|
297
|
-
<text fg={mutedColor()}>
|
|
298
|
-
{
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
{
|
|
308
|
-
{
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
<text fg={mutedColor()}> {t("
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
325
|
+
return (<box flexDirection="column">
|
|
326
|
+
<box onMouseDown={() => toggle.model(key)}>
|
|
327
|
+
<text fg={primaryColor()}>{modelCollapsed() ? "▼" : "▶"} {shortTitle}</text>
|
|
328
|
+
</box>
|
|
329
|
+
<text fg={mutedColor()}>
|
|
330
|
+
{/* Bug fix: total 改为 5 分量(含 reasoning) */}
|
|
331
|
+
{t("total")}:{formatTokens(stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite)} {t("requests")}:{stat.requestCount}
|
|
332
|
+
</text>
|
|
333
|
+
<text fg={mutedColor()}>
|
|
334
|
+
{t("input")}:{formatTokens(stat.totalInput)} {t("output")}:{formatTokens(stat.totalOutput)}
|
|
335
|
+
</text>
|
|
336
|
+
<text fg={mutedColor()}>
|
|
337
|
+
{t("cache")}:{formatTokens(stat.cacheRead + stat.cacheWrite)}(
|
|
338
|
+
<span style={{ fg: hitRateColor(hitRate) }}>
|
|
339
|
+
{progressFilled(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
|
|
340
|
+
{progressRemaining(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
|
|
341
|
+
{" "}{hitRate.toFixed(0)}%
|
|
342
|
+
</span>
|
|
343
|
+
{trendStr ? ` ${trendStr}` : ""})
|
|
344
|
+
</text>
|
|
345
|
+
<Show when={stat.totalCost > 0}>
|
|
346
|
+
<text fg={mutedColor()}>
|
|
347
|
+
{t("cost")}:{formatCost(stat.totalCost)}
|
|
348
|
+
</text>
|
|
349
|
+
</Show>
|
|
350
|
+
|
|
351
|
+
<Show when={modelCollapsed()}>
|
|
352
|
+
<Show when={config().sidebar.showPerformance}>
|
|
353
|
+
<box flexDirection="column">
|
|
354
|
+
<box onMouseDown={() => toggle.sub(`perf-${key}`)}>
|
|
355
|
+
<text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
|
|
356
|
+
</box>
|
|
357
|
+
<Show when={!collapse().subBlocks[`perf-${key}`]}>
|
|
358
|
+
<text fg={mutedColor()}>
|
|
359
|
+
{t("ttft")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgTTFT ?? null)} {t("tps")}:{perfTracker.getSessionStats().models[key]?.avgTPS?.toFixed(1) ?? "—"} {t("latency")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgLatency ?? null)}
|
|
360
|
+
</text>
|
|
361
|
+
</Show>
|
|
362
|
+
</box>
|
|
363
|
+
</Show>
|
|
364
|
+
|
|
365
|
+
<Show when={config().sidebar.showPricing && stat.totalCost > 0}>
|
|
366
|
+
<box flexDirection="column">
|
|
367
|
+
<box onMouseDown={() => toggle.sub(`pricing-${key}`)}>
|
|
368
|
+
<text fg={textColor()}>{!collapse().subBlocks[`pricing-${key}`] ? "▼" : "▶"} ── {t("pricing")} ──</text>
|
|
369
|
+
</box>
|
|
370
|
+
<Show when={!collapse().subBlocks[`pricing-${key}`]}>
|
|
371
|
+
<text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
|
|
372
|
+
<text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
|
|
373
|
+
</Show>
|
|
374
|
+
</box>
|
|
375
|
+
</Show>
|
|
376
|
+
</Show>
|
|
344
377
|
</box>);
|
|
345
|
-
}}
|
|
346
|
-
</For>
|
|
347
|
-
|
|
348
|
-
<Show when={config().sidebar.showTokenDistribution}>
|
|
349
|
-
<box flexDirection="column">
|
|
350
|
-
<box onMouseDown={() => toggle.sub("token-dist")}>
|
|
351
|
-
<text fg={primaryColor()}>{!collapse().subBlocks["token-dist"] ? "▼" : "▶"} ── {t("tokenDistribution")} ──</text>
|
|
352
|
-
</box>
|
|
353
|
-
<Show when={!collapse().subBlocks["token-dist"]}>
|
|
354
|
-
<For each={Object.entries(tokenDistribution())}>
|
|
355
|
-
{([role, tokens]) => (<text fg={mutedColor()}> {t(role)}:{formatTokens(tokens)}</text>)}
|
|
356
|
-
</For>
|
|
357
|
-
</Show>
|
|
358
|
-
</box>
|
|
359
|
-
</Show>
|
|
360
|
-
</Show>
|
|
378
|
+
}}
|
|
379
|
+
</For>
|
|
380
|
+
|
|
381
|
+
<Show when={config().sidebar.showTokenDistribution}>
|
|
382
|
+
<box flexDirection="column">
|
|
383
|
+
<box onMouseDown={() => toggle.sub("token-dist")}>
|
|
384
|
+
<text fg={primaryColor()}>{!collapse().subBlocks["token-dist"] ? "▼" : "▶"} ── {t("tokenDistribution")} ──</text>
|
|
385
|
+
</box>
|
|
386
|
+
<Show when={!collapse().subBlocks["token-dist"]}>
|
|
387
|
+
<For each={Object.entries(tokenDistribution())}>
|
|
388
|
+
{([role, tokens]) => (<text fg={mutedColor()}> {t(role)}:{formatTokens(tokens)}</text>)}
|
|
389
|
+
</For>
|
|
390
|
+
</Show>
|
|
391
|
+
</box>
|
|
392
|
+
</Show>
|
|
393
|
+
</Show>
|
|
361
394
|
</box>);
|
|
362
395
|
}
|
package/dist/tui.jsx
CHANGED
|
@@ -44,7 +44,10 @@ const tui = async (api) => {
|
|
|
44
44
|
else {
|
|
45
45
|
next = [...prev, msg];
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// Bug fix: 优先使用事件自带的 sessionID,而非 currentSlotSessionID
|
|
48
|
+
// currentSlotSessionID 在 slot 首次渲染时才更新,session 切换瞬间可能落后
|
|
49
|
+
const targetSessionID = info.sessionID ?? currentSlotSessionID;
|
|
50
|
+
persistToKv(targetSessionID, next);
|
|
48
51
|
return next;
|
|
49
52
|
});
|
|
50
53
|
}
|
package/package.json
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "opencode-tokenwatch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
|
-
"exports": {
|
|
9
|
-
".": {
|
|
10
|
-
"types": "./dist/index.d.ts",
|
|
11
|
-
"import": "./dist/index.js"
|
|
12
|
-
},
|
|
13
|
-
"./tui": {
|
|
14
|
-
"types": "./dist/tui.d.ts",
|
|
15
|
-
"import": "./dist/tui.jsx"
|
|
16
|
-
},
|
|
17
|
-
"./package.json": "./package.json"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist"
|
|
21
|
-
],
|
|
22
|
-
"sideEffects": false,
|
|
23
|
-
"engines": {
|
|
24
|
-
"node": ">=18"
|
|
25
|
-
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"build": "tsc",
|
|
28
|
-
"release:check": "node ./scripts/publish-check.mjs",
|
|
29
|
-
"prepublishOnly": "npm run build"
|
|
30
|
-
},
|
|
31
|
-
"keywords": [
|
|
32
|
-
"opencode",
|
|
33
|
-
"plugin",
|
|
34
|
-
"opencode-plugin",
|
|
35
|
-
"tokens",
|
|
36
|
-
"usage",
|
|
37
|
-
"stats",
|
|
38
|
-
"sqlite",
|
|
39
|
-
"analytics",
|
|
40
|
-
"tui"
|
|
41
|
-
],
|
|
42
|
-
"license": "MIT",
|
|
43
|
-
"author": "TTWK",
|
|
44
|
-
"repository": {
|
|
45
|
-
"type": "git",
|
|
46
|
-
"url": "git+https://github.com/TTWK/opencode-tokenwatch.git"
|
|
47
|
-
},
|
|
48
|
-
"bugs": {
|
|
49
|
-
"url": "https://github.com/TTWK/opencode-tokenwatch/issues"
|
|
50
|
-
},
|
|
51
|
-
"homepage": "https://github.com/TTWK/opencode-tokenwatch#readme",
|
|
52
|
-
"devDependencies": {
|
|
53
|
-
"@opencode-ai/plugin": "latest",
|
|
54
|
-
"@opentui/core": "^0.2.9",
|
|
55
|
-
"@opentui/keymap": "^0.2.9",
|
|
56
|
-
"@opentui/solid": "^0.2.9",
|
|
57
|
-
"@types/node": "^22.0.0",
|
|
58
|
-
"typescript": "^5.7.0"
|
|
59
|
-
},
|
|
60
|
-
"publishConfig": {
|
|
61
|
-
"access": "public"
|
|
62
|
-
}
|
|
63
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-tokenwatch",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Real-time token usage, cache analytics & performance dashboard plugin for OpenCode CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./tui": {
|
|
14
|
+
"types": "./dist/tui.d.ts",
|
|
15
|
+
"import": "./dist/tui.jsx"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"release:check": "node ./scripts/publish-check.mjs",
|
|
29
|
+
"prepublishOnly": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"opencode",
|
|
33
|
+
"plugin",
|
|
34
|
+
"opencode-plugin",
|
|
35
|
+
"tokens",
|
|
36
|
+
"usage",
|
|
37
|
+
"stats",
|
|
38
|
+
"sqlite",
|
|
39
|
+
"analytics",
|
|
40
|
+
"tui"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"author": "TTWK",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/TTWK/opencode-tokenwatch.git"
|
|
47
|
+
},
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/TTWK/opencode-tokenwatch/issues"
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/TTWK/opencode-tokenwatch#readme",
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@opencode-ai/plugin": "latest",
|
|
54
|
+
"@opentui/core": "^0.2.9",
|
|
55
|
+
"@opentui/keymap": "^0.2.9",
|
|
56
|
+
"@opentui/solid": "^0.2.9",
|
|
57
|
+
"@types/node": "^22.0.0",
|
|
58
|
+
"typescript": "^5.7.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/commands.js
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import { getUsageReport } from "./queries.js";
|
|
2
|
-
import { formatUsageReport } from "./formatter.js";
|
|
3
|
-
import { generateUsageHtml } from "./generate-usage-html.js";
|
|
4
|
-
import { t } from "./i18n.js";
|
|
5
|
-
import { readLogs } from "./perf-tracker.js";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
|
-
import { execSync } from "node:child_process";
|
|
10
|
-
const DEFAULT_CONFIG = {
|
|
11
|
-
sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
|
|
12
|
-
language: "auto",
|
|
13
|
-
};
|
|
14
|
-
export async function registerCommands(api) {
|
|
15
|
-
api.command?.register(() => [
|
|
16
|
-
{
|
|
17
|
-
value: "tokenwatch-html-report",
|
|
18
|
-
title: "Generate HTML report",
|
|
19
|
-
description: "Generate an HTML dashboard with token usage, cache efficiency, and performance charts",
|
|
20
|
-
category: "Stats",
|
|
21
|
-
slash: { name: "usage-html", aliases: ["usage"] },
|
|
22
|
-
onSelect: async () => {
|
|
23
|
-
await showHtmlReport(api);
|
|
24
|
-
},
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
value: "tokenwatch-json-export",
|
|
28
|
-
title: "Export as JSON",
|
|
29
|
-
description: "Export usage data as JSON file",
|
|
30
|
-
category: "Stats",
|
|
31
|
-
slash: { name: "usage-json" },
|
|
32
|
-
onSelect: async () => {
|
|
33
|
-
await showJsonExport(api);
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
value: "tokenwatch-text-report",
|
|
38
|
-
title: "Text report (legacy)",
|
|
39
|
-
description: "View plain text usage report in terminal",
|
|
40
|
-
category: "Stats",
|
|
41
|
-
slash: { name: "usage-text" },
|
|
42
|
-
onSelect: async () => {
|
|
43
|
-
await showTextReport(api);
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
value: "tokenwatch-settings",
|
|
48
|
-
title: "TokenWatch Settings",
|
|
49
|
-
description: "Configure sidebar display options",
|
|
50
|
-
category: "Stats",
|
|
51
|
-
slash: { name: "usage-settings", aliases: ["tokenwatch-settings"] },
|
|
52
|
-
onSelect: async () => {
|
|
53
|
-
await showSettingsDialog(api);
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
]);
|
|
57
|
-
}
|
|
58
|
-
function ensureReportDir() {
|
|
59
|
-
const dir = join(homedir(), ".opencode", "reports");
|
|
60
|
-
if (!existsSync(dir))
|
|
61
|
-
mkdirSync(dir, { recursive: true });
|
|
62
|
-
return dir;
|
|
63
|
-
}
|
|
64
|
-
function openInBrowser(filePath) {
|
|
65
|
-
try {
|
|
66
|
-
const platform = process.platform;
|
|
67
|
-
if (platform === "win32")
|
|
68
|
-
execSync(`start "" "${filePath}"`, { windowsHide: true, timeout: 5000 });
|
|
69
|
-
else if (platform === "darwin")
|
|
70
|
-
execSync(`open "${filePath}"`, { timeout: 5000 });
|
|
71
|
-
else
|
|
72
|
-
execSync(`xdg-open "${filePath}"`, { timeout: 5000 });
|
|
73
|
-
}
|
|
74
|
-
catch { /* silently fail */ }
|
|
75
|
-
}
|
|
76
|
-
function aggregatePerfStats(logs) {
|
|
77
|
-
const map = new Map();
|
|
78
|
-
for (const entry of logs) {
|
|
79
|
-
const key = entry.model;
|
|
80
|
-
let s = map.get(key);
|
|
81
|
-
if (!s) {
|
|
82
|
-
s = {
|
|
83
|
-
model: key,
|
|
84
|
-
providerID: entry.providerID,
|
|
85
|
-
requestCount: 0,
|
|
86
|
-
totalInput: 0, totalOutput: 0, totalCacheRead: 0, totalCacheWrite: 0, totalCost: 0,
|
|
87
|
-
avgTTFT: null, maxTTFT: null, minTTFT: null,
|
|
88
|
-
avgTPS: null, maxTPS: null, minTPS: null,
|
|
89
|
-
avgLatency: null, maxLatency: null, minLatency: null,
|
|
90
|
-
};
|
|
91
|
-
map.set(key, s);
|
|
92
|
-
}
|
|
93
|
-
s.requestCount++;
|
|
94
|
-
s.totalInput += entry.inputTokens;
|
|
95
|
-
s.totalOutput += entry.outputTokens;
|
|
96
|
-
s.totalCacheRead += entry.cacheReadTokens;
|
|
97
|
-
s.totalCacheWrite += entry.cacheWriteTokens;
|
|
98
|
-
s.totalCost += entry.cost;
|
|
99
|
-
const c = s.requestCount;
|
|
100
|
-
if (entry.ttft_ms != null) {
|
|
101
|
-
s.avgTTFT = s.avgTTFT != null ? s.avgTTFT + (entry.ttft_ms - s.avgTTFT) / c : entry.ttft_ms;
|
|
102
|
-
s.maxTTFT = s.maxTTFT != null ? Math.max(s.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
103
|
-
s.minTTFT = s.minTTFT != null ? Math.min(s.minTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
104
|
-
}
|
|
105
|
-
if (entry.tps != null) {
|
|
106
|
-
s.avgTPS = s.avgTPS != null ? s.avgTPS + (entry.tps - s.avgTPS) / c : entry.tps;
|
|
107
|
-
s.maxTPS = s.maxTPS != null ? Math.max(s.maxTPS, entry.tps) : entry.tps;
|
|
108
|
-
s.minTPS = s.minTPS != null ? Math.min(s.minTPS, entry.tps) : entry.tps;
|
|
109
|
-
}
|
|
110
|
-
if (entry.latency_ms != null) {
|
|
111
|
-
s.avgLatency = s.avgLatency != null ? s.avgLatency + (entry.latency_ms - s.avgLatency) / c : entry.latency_ms;
|
|
112
|
-
s.maxLatency = s.maxLatency != null ? Math.max(s.maxLatency, entry.latency_ms) : entry.latency_ms;
|
|
113
|
-
s.minLatency = s.minLatency != null ? Math.min(s.minLatency, entry.latency_ms) : entry.latency_ms;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return Array.from(map.values());
|
|
117
|
-
}
|
|
118
|
-
async function buildCombinedData(api) {
|
|
119
|
-
const report = await getUsageReport({});
|
|
120
|
-
const logs = readLogs(1000);
|
|
121
|
-
const now = new Date();
|
|
122
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
123
|
-
const meta = {
|
|
124
|
-
generatedAt: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
|
|
125
|
-
dateRange: {
|
|
126
|
-
start: report.daily.length > 0 ? report.daily[report.daily.length - 1].day : "—",
|
|
127
|
-
end: report.daily.length > 0 ? report.daily[0].day : "—",
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
return {
|
|
131
|
-
...report,
|
|
132
|
-
perfLogs: logs,
|
|
133
|
-
perfSummary: aggregatePerfStats(logs),
|
|
134
|
-
meta,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
async function showHtmlReport(api) {
|
|
138
|
-
try {
|
|
139
|
-
const data = await buildCombinedData(api);
|
|
140
|
-
const html = generateUsageHtml(data);
|
|
141
|
-
const dir = ensureReportDir();
|
|
142
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
143
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.html`);
|
|
144
|
-
writeFileSync(filePath, html, "utf-8");
|
|
145
|
-
api.ui.toast?.({ message: `Report: ${filePath}`, variant: "info" });
|
|
146
|
-
openInBrowser(filePath);
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
150
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
async function showJsonExport(api) {
|
|
154
|
-
try {
|
|
155
|
-
const report = await getUsageReport({});
|
|
156
|
-
const dir = ensureReportDir();
|
|
157
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
158
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.json`);
|
|
159
|
-
writeFileSync(filePath, JSON.stringify(report, null, 2), "utf-8");
|
|
160
|
-
api.ui.toast?.({ message: `JSON: ${filePath}`, variant: "info" });
|
|
161
|
-
}
|
|
162
|
-
catch (err) {
|
|
163
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
async function showTextReport(api) {
|
|
168
|
-
try {
|
|
169
|
-
const report = await getUsageReport({});
|
|
170
|
-
const formatted = formatUsageReport(report);
|
|
171
|
-
const dir = ensureReportDir();
|
|
172
|
-
const dateStr = new Date().toISOString().slice(0, 10);
|
|
173
|
-
const filePath = join(dir, `tokenwatch-${dateStr}.md`);
|
|
174
|
-
writeFileSync(filePath, formatted, "utf-8");
|
|
175
|
-
api.ui.toast?.({ message: `Report saved to ${filePath}`, variant: "info" });
|
|
176
|
-
}
|
|
177
|
-
catch (err) {
|
|
178
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
179
|
-
api.ui.toast?.({ message: `Error: ${msg}`, variant: "error" });
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
async function showSettingsDialog(api) {
|
|
183
|
-
const currentConfig = loadConfigFromStore(api);
|
|
184
|
-
const cfg = currentConfig.sidebar;
|
|
185
|
-
const options = [
|
|
186
|
-
`[${cfg.showPerformance ? "x" : " "}] ${t("showPerformance")}`,
|
|
187
|
-
`[${cfg.showPricing ? "x" : " "}] ${t("showPricing")}`,
|
|
188
|
-
`[${cfg.showTokenDistribution ? "x" : " "}] ${t("showTokenDistribution")}`,
|
|
189
|
-
`[${cfg.showTrend ? "x" : " "}] ${t("showTrend")}`,
|
|
190
|
-
`---`,
|
|
191
|
-
`${t("language")}: ${currentConfig.language}`,
|
|
192
|
-
].join("\n");
|
|
193
|
-
api.ui.toast?.({ message: `TokenWatch settings:\n${options}`, variant: "info" });
|
|
194
|
-
}
|
|
195
|
-
function loadConfigFromStore(api) {
|
|
196
|
-
const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
|
|
197
|
-
try {
|
|
198
|
-
const stored = api.kv?.get?.("tokenwatch-config");
|
|
199
|
-
if (stored) {
|
|
200
|
-
if (stored.sidebar)
|
|
201
|
-
Object.assign(base.sidebar, stored.sidebar);
|
|
202
|
-
if (stored.language)
|
|
203
|
-
base.language = stored.language;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
catch { /* defaults */ }
|
|
207
|
-
return base;
|
|
208
|
-
}
|