opencode-tokenwatch 0.1.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.
@@ -0,0 +1,395 @@
1
+ import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
2
+ import { RGBA } from "@opentui/core";
3
+ import { formatTokens, formatCost, formatDuration } from "./formatter.js";
4
+ import { t, setLanguage } from "./i18n.js";
5
+ const DEFAULT_CONFIG = {
6
+ sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
7
+ language: "auto",
8
+ };
9
+ function progressBarWidth(percent, width) {
10
+ if (percent >= 100)
11
+ return width;
12
+ return Math.floor((percent / 100) * width);
13
+ }
14
+ function progressFilled(percent, width) {
15
+ return "█".repeat(Math.max(0, progressBarWidth(percent, width)));
16
+ }
17
+ function progressRemaining(percent, width) {
18
+ return "░".repeat(Math.max(0, width - progressBarWidth(percent, width)));
19
+ }
20
+ function hitRateColor(rate) {
21
+ if (rate >= 85)
22
+ return RGBA.fromInts(76, 175, 80, 255);
23
+ if (rate >= 70)
24
+ return RGBA.fromInts(255, 193, 7, 255);
25
+ return RGBA.fromInts(244, 67, 54, 255);
26
+ }
27
+ function estimateTokens(text) {
28
+ if (!text || text.length === 0)
29
+ return 0;
30
+ let ascii = 0, cjk = 0;
31
+ for (const c of text) {
32
+ const code = c.codePointAt(0) ?? 0;
33
+ if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3040 && code <= 0x30FF) ||
34
+ (code >= 0xAC00 && code <= 0xD7A3) || (code >= 0x1100 && code <= 0x11FF) ||
35
+ (code >= 0x2E80 && code <= 0x2EFF))
36
+ cjk++;
37
+ else
38
+ ascii++;
39
+ }
40
+ const trimmed = text.trimStart();
41
+ const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("[")) && /"[^"]+"\s*:/.test(text);
42
+ const codeLike = !jsonLike && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
43
+ const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
44
+ return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
45
+ }
46
+ function loadCollapseState(api) {
47
+ try {
48
+ return api.kv?.get?.("tokenwatch-collapse") ?? { global: false, models: {}, subBlocks: {} };
49
+ }
50
+ catch {
51
+ return { global: false, models: {}, subBlocks: {} };
52
+ }
53
+ }
54
+ function saveCollapseState(api, state) {
55
+ try {
56
+ api.kv?.set?.("tokenwatch-collapse", state);
57
+ }
58
+ catch { /* non-critical */ }
59
+ }
60
+ export function loadConfig(api) {
61
+ const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
62
+ try {
63
+ const pluginCfg = api.config?.pluginConfig?.["opencode-tokenwatch"];
64
+ if (pluginCfg?.sidebar)
65
+ Object.assign(base.sidebar, pluginCfg.sidebar);
66
+ if (pluginCfg?.language)
67
+ base.language = pluginCfg.language;
68
+ const overrides = api.kv?.get?.("tokenwatch-config");
69
+ if (overrides?.sidebar)
70
+ Object.assign(base.sidebar, overrides.sidebar);
71
+ if (overrides?.language)
72
+ base.language = overrides.language;
73
+ }
74
+ catch { /* defaults */ }
75
+ return base;
76
+ }
77
+ export function TokenWatchPanel(props) {
78
+ const { api, theme, perfTracker } = props;
79
+ const getMessages = () => props.messages;
80
+ const [config, setConfig] = createSignal(loadConfig(api));
81
+ let knownCfgVer = api.kv?.get?.("tokenwatch-config-version");
82
+ createEffect(() => {
83
+ const timer = setInterval(() => {
84
+ const v = api.kv?.get?.("tokenwatch-config-version");
85
+ if (v != null && v !== knownCfgVer) {
86
+ knownCfgVer = v;
87
+ setConfig(loadConfig(api));
88
+ }
89
+ }, 500);
90
+ onCleanup(() => clearInterval(timer));
91
+ });
92
+ const [collapse, setCollapse] = createSignal(loadCollapseState(api));
93
+ const [panelWidth, setPanelWidth] = createSignal(40);
94
+ createEffect(() => setLanguage(config().language));
95
+ const primaryColor = () => theme.current.primary;
96
+ const mutedColor = () => theme.current.textMuted;
97
+ const textColor = () => theme.current.text;
98
+ const modelStats = createMemo(() => {
99
+ const map = new Map();
100
+ for (const msg of props.allTokenMessages) {
101
+ const key = `${msg.providerID}/${msg.modelID}`;
102
+ let e = map.get(key);
103
+ if (!e) {
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 };
106
+ map.set(key, e);
107
+ }
108
+ e.totalInput += msg.inputTokens;
109
+ e.totalOutput += msg.outputTokens;
110
+ e.totalReasoning += msg.reasoningTokens; // Bug fix: 聚合 reasoning
111
+ e.cacheRead += msg.cacheRead;
112
+ e.cacheWrite += msg.cacheWrite;
113
+ e.totalCost += msg.cost;
114
+ e.requestCount++;
115
+ }
116
+ return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
117
+ });
118
+ const sessionTotals = createMemo(() => {
119
+ let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
120
+ for (const [, s] of modelStats()) {
121
+ i += s.totalInput;
122
+ o += s.totalOutput;
123
+ ir += s.totalReasoning;
124
+ cr += s.cacheRead;
125
+ cw += s.cacheWrite;
126
+ r += s.requestCount;
127
+ c += s.totalCost;
128
+ }
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 };
131
+ });
132
+ const modelHitRate = createMemo(() => {
133
+ return modelStats().map(([key, stat]) => {
134
+ const denom = stat.totalInput + stat.cacheRead;
135
+ if (denom === 0)
136
+ return { key, rate: 0, msgs: [] };
137
+ const msgs = [];
138
+ for (const msg of props.allTokenMessages) {
139
+ const pk = `${msg.providerID}/${msg.modelID}`;
140
+ if (pk !== key)
141
+ continue;
142
+ msgs.push(msg);
143
+ }
144
+ return { key, rate: (stat.cacheRead / denom) * 100, msgs };
145
+ });
146
+ });
147
+ const modelTrend = createMemo(() => {
148
+ return modelHitRate().map(({ key, msgs }) => {
149
+ if (msgs.length < 6)
150
+ return { key, trend: null };
151
+ const sumSlice = (start, end) => {
152
+ let sumCache = 0, sumTotal = 0;
153
+ for (let i = start; i < end && i < msgs.length; i++) {
154
+ const input = msgs[i].inputTokens;
155
+ const cache = msgs[i].cacheRead;
156
+ sumCache += cache;
157
+ sumTotal += input + cache;
158
+ }
159
+ return { sumCache, sumTotal };
160
+ };
161
+ const n = msgs.length;
162
+ const recent = sumSlice(n - 3, n);
163
+ const prev = sumSlice(n - 6, n - 3);
164
+ const rateRecent = recent.sumTotal > 0 ? (recent.sumCache / recent.sumTotal) * 100 : 0;
165
+ const ratePrev = prev.sumTotal > 0 ? (prev.sumCache / prev.sumTotal) * 100 : 0;
166
+ return { key, trend: rateRecent - ratePrev };
167
+ });
168
+ });
169
+ const [partVersion, setPartVersion] = createSignal(0);
170
+ const tokenDistribution = createMemo(() => {
171
+ void partVersion();
172
+ const dist = {};
173
+ try {
174
+ const cfg = api.state.config;
175
+ const agents = cfg?.agent;
176
+ if (agents) {
177
+ for (const ac of Object.values(agents)) {
178
+ const a = ac;
179
+ if (typeof a?.prompt === "string" && a.prompt) {
180
+ dist.system = estimateTokens(a.prompt);
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ }
186
+ catch { }
187
+ for (const msg of getMessages()) {
188
+ const role = msg.role;
189
+ if (role === "user") {
190
+ if (msg.system)
191
+ dist.system = (dist.system ?? 0) + estimateTokens(msg.system);
192
+ let parts = [];
193
+ try {
194
+ parts = api.state.part(msg.id);
195
+ }
196
+ catch {
197
+ continue;
198
+ }
199
+ for (const p of parts) {
200
+ if (p.type === "text" && !p.synthetic && !p.ignored) {
201
+ dist.user = (dist.user ?? 0) + estimateTokens(p.text ?? "");
202
+ }
203
+ else if (p.type === "file" && p.source?.text?.value) {
204
+ dist.user = (dist.user ?? 0) + estimateTokens(p.source.text.value);
205
+ }
206
+ }
207
+ }
208
+ else if (role === "assistant") {
209
+ let parts = [];
210
+ try {
211
+ parts = api.state.part(msg.id);
212
+ }
213
+ catch {
214
+ continue;
215
+ }
216
+ for (const p of parts) {
217
+ if (p.type === "tool") {
218
+ let rawInput = "";
219
+ try {
220
+ rawInput = p.state?.raw ?? JSON.stringify(p.state?.input);
221
+ }
222
+ catch {
223
+ try {
224
+ rawInput = JSON.stringify(p.state);
225
+ }
226
+ catch { }
227
+ }
228
+ if (rawInput)
229
+ dist.toolCall = (dist.toolCall ?? 0) + estimateTokens(rawInput);
230
+ if (p.state?.status === "completed" && p.state?.output) {
231
+ dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.output);
232
+ }
233
+ else if (p.state?.status === "error" && p.state?.error) {
234
+ dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
235
+ }
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
+ }
242
+ else if (p.type === "reasoning") {
243
+ dist.agent = (dist.agent ?? 0) + estimateTokens(p.text ?? "");
244
+ }
245
+ else if (p.type === "subtask") {
246
+ dist.agent = (dist.agent ?? 0) + estimateTokens(p.prompt || p.description || "");
247
+ }
248
+ }
249
+ const tokens = msg.tokens;
250
+ // 真实 output token 数优先:覆盖上面的 text part 估算
251
+ if (tokens?.output)
252
+ dist.output = (dist.output ?? 0) + tokens.output;
253
+ }
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
+ }
266
+ return dist;
267
+ });
268
+ const toggle = {
269
+ global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
270
+ model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
271
+ sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
272
+ };
273
+ onMount(() => {
274
+ // Risk fix: 移除 message.updated 的重复订阅。
275
+ // tui.tsx 已通过 setSidebarRevision() 驱动 sidebar 整体重渲,
276
+ // sidebar 内部只需订阅 part.updated 来触发 tokenDistribution 重算。
277
+ const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
278
+ onCleanup(() => { try {
279
+ unsubPart?.();
280
+ }
281
+ catch { } });
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()}>
315
+ {([key, stat]) => {
316
+ const modelCollapsed = () => collapse().models[key] !== true;
317
+ const totalInput = stat.totalInput + stat.cacheRead;
318
+ const hitRate = totalInput > 0 ? (stat.cacheRead / totalInput) * 100 : 0;
319
+ const trendData = modelTrend().find(h => h.key === key);
320
+ const trendStr = trendData?.trend !== null && trendData?.trend !== undefined && trendData.trend !== 0
321
+ ? (trendData.trend >= 0 ? `${t("trendUp")}${trendData.trend.toFixed(1)}%` : `${t("trendDown")}${Math.abs(trendData.trend).toFixed(1)}%`)
322
+ : "";
323
+ const title = `${stat.providerID}/${stat.modelID}`;
324
+ const shortTitle = title.length > 32 ? title.slice(0, 30) + "…" : title;
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>
377
+ </box>);
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>
394
+ </box>);
395
+ }
package/dist/tui.d.ts CHANGED
@@ -1,3 +1,17 @@
1
1
  import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
2
- declare const plugin: TuiPluginModule;
2
+ export interface TokenMessage {
3
+ id: string;
4
+ sessionID: string;
5
+ providerID: string;
6
+ modelID: string;
7
+ inputTokens: number;
8
+ outputTokens: number;
9
+ reasoningTokens: number;
10
+ cacheRead: number;
11
+ cacheWrite: number;
12
+ cost: number;
13
+ }
14
+ declare const plugin: TuiPluginModule & {
15
+ id: string;
16
+ };
3
17
  export default plugin;
package/dist/tui.jsx ADDED
@@ -0,0 +1,123 @@
1
+ import { createSignal } from "solid-js";
2
+ import { registerCommands } from "./commands.jsx";
3
+ import { createPerfTracker } from "./perf-tracker.js";
4
+ import { TokenWatchPanel } from "./sidebar.jsx";
5
+ function kvKey(sessionID) {
6
+ return "tokenwatch-msgs-" + sessionID;
7
+ }
8
+ const tui = async (api) => {
9
+ const perfTracker = createPerfTracker();
10
+ const [sidebarRevision, setSidebarRevision] = createSignal(0);
11
+ const [allTokenMessages, setAllTokenMessages] = createSignal([]);
12
+ let currentSlotSessionID = "";
13
+ const cleanups = [];
14
+ registerCommands(api);
15
+ function persistToKv(sessionID, msgs) {
16
+ try {
17
+ api.kv?.set?.(kvKey(sessionID), msgs);
18
+ }
19
+ catch { /* non-critical */ }
20
+ }
21
+ const unsubMsgUpdated = api.event.on("message.updated", (event) => {
22
+ const info = event.properties?.info;
23
+ perfTracker.handleMessageUpdated(event);
24
+ if (info?.role === "assistant" && info?.tokens?.total > 0) {
25
+ setAllTokenMessages(prev => {
26
+ const msg = {
27
+ id: info.id,
28
+ sessionID: info.sessionID ?? "",
29
+ providerID: info.providerID ?? "unknown",
30
+ modelID: info.modelID ?? "unknown",
31
+ inputTokens: info.tokens?.input ?? 0,
32
+ outputTokens: info.tokens?.output ?? 0,
33
+ reasoningTokens: info.tokens?.reasoning ?? 0,
34
+ cacheRead: info.tokens?.cache?.read ?? 0,
35
+ cacheWrite: info.tokens?.cache?.write ?? 0,
36
+ cost: info.cost ?? 0,
37
+ };
38
+ const idx = prev.findIndex(m => m.id === msg.id);
39
+ let next;
40
+ if (idx >= 0) {
41
+ next = [...prev];
42
+ next[idx] = msg;
43
+ }
44
+ else {
45
+ next = [...prev, msg];
46
+ }
47
+ // Bug fix: 优先使用事件自带的 sessionID,而非 currentSlotSessionID
48
+ // currentSlotSessionID 在 slot 首次渲染时才更新,session 切换瞬间可能落后
49
+ const targetSessionID = info.sessionID ?? currentSlotSessionID;
50
+ persistToKv(targetSessionID, next);
51
+ return next;
52
+ });
53
+ }
54
+ setSidebarRevision((v) => v + 1);
55
+ });
56
+ cleanups.push(unsubMsgUpdated);
57
+ const unsubPartUpdated = api.event.on("message.part.updated", (event) => {
58
+ perfTracker.handlePartUpdated({
59
+ message_id: event.properties?.part?.messageID,
60
+ type: event.properties?.part?.type,
61
+ text: event.properties?.part?.type === "text" ? event.properties?.part?.text : undefined,
62
+ time: { start: event.properties?.part?.time?.start },
63
+ });
64
+ });
65
+ cleanups.push(unsubPartUpdated);
66
+ const unsubRemoved = api.event.on("message.removed", () => {
67
+ setSidebarRevision((v) => v + 1);
68
+ });
69
+ cleanups.push(unsubRemoved);
70
+ api.lifecycle?.onDispose?.(() => {
71
+ for (const cleanup of cleanups)
72
+ cleanup();
73
+ });
74
+ api.slots.register({
75
+ order: 50,
76
+ slots: {
77
+ sidebar_content: (_ctx, { session_id }) => {
78
+ sidebarRevision();
79
+ if (session_id && session_id !== currentSlotSessionID) {
80
+ currentSlotSessionID = session_id;
81
+ perfTracker.reset();
82
+ let loaded = [];
83
+ try {
84
+ const saved = api.kv?.get?.(kvKey(session_id));
85
+ if (saved && saved.length > 0)
86
+ loaded = saved;
87
+ }
88
+ catch { }
89
+ if (loaded.length === 0) {
90
+ const existing = api.state.session.messages(session_id);
91
+ for (const msg of existing) {
92
+ if (msg.role !== "assistant")
93
+ continue;
94
+ const tokens = msg.tokens;
95
+ if (!tokens)
96
+ continue;
97
+ loaded.push({
98
+ id: msg.id,
99
+ sessionID: session_id,
100
+ providerID: msg.providerID ?? "unknown",
101
+ modelID: msg.modelID ?? "unknown",
102
+ inputTokens: tokens?.input ?? 0,
103
+ outputTokens: tokens?.output ?? 0,
104
+ reasoningTokens: tokens?.reasoning ?? 0,
105
+ cacheRead: tokens?.cache?.read ?? 0,
106
+ cacheWrite: tokens?.cache?.write ?? 0,
107
+ cost: msg.cost ?? 0,
108
+ });
109
+ }
110
+ }
111
+ setAllTokenMessages(loaded);
112
+ }
113
+ const messages = api.state.session.messages(session_id);
114
+ return <TokenWatchPanel api={api} theme={api.theme} perfTracker={perfTracker} messages={messages} allTokenMessages={allTokenMessages()}/>;
115
+ },
116
+ },
117
+ });
118
+ };
119
+ const plugin = {
120
+ id: "opencode-tokenwatch",
121
+ tui,
122
+ };
123
+ export default plugin;
package/package.json CHANGED
@@ -1,63 +1,63 @@
1
- {
2
- "name": "opencode-tokenwatch",
3
- "version": "0.1.0",
4
- "description": "Token usage analytics plugin for OpenCode with live sidebar stats and /usage reports",
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.js"
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
+ }