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.
package/dist/tui.js DELETED
@@ -1,406 +0,0 @@
1
- import { createSignal } from "solid-js";
2
- import { createElement, createTextNode, insertNode, setProp, useKeyHandler } from "@opentui/solid";
3
- import { formatFilters, formatTokens, formatUsageReport, } from "./formatter.js";
4
- import { exportReportAsCsv, getAvailableModels, getAvailableProviders, getPresetRange, getUsageReport, } from "./queries.js";
5
- import fs from "node:fs";
6
- import path from "node:path";
7
- function ReportScreen(props) {
8
- const reportJson = props.params?.reportJson ?? "{}";
9
- const filtersJson = props.params?.filtersJson ?? "{}";
10
- const lastSessionId = props.params?.lastSessionId;
11
- // Add key handler to close report
12
- useKeyHandler((event) => {
13
- if (event.name === "escape" || event.name === "esc" || event.name === "q") {
14
- event.stopPropagation();
15
- if (lastSessionId) {
16
- props.api.route.navigate("session", { sessionID: lastSessionId });
17
- }
18
- else {
19
- props.api.route.navigate("home");
20
- }
21
- }
22
- });
23
- let report;
24
- let filters;
25
- try {
26
- report = JSON.parse(reportJson);
27
- filters = JSON.parse(filtersJson);
28
- }
29
- catch {
30
- report = null;
31
- filters = null;
32
- }
33
- const output = report && filters
34
- ? formatUsageReport({ filters, ...report })
35
- : "Failed to load report data.";
36
- const container = createElement("box");
37
- setProp(container, "flexDirection", "column");
38
- setProp(container, "width", "100%");
39
- setProp(container, "height", "100%");
40
- const header = createElement("box");
41
- setProp(header, "flexDirection", "row");
42
- setProp(header, "justifyContent", "space-between");
43
- setProp(header, "paddingX", 2);
44
- setProp(header, "paddingY", 1);
45
- const titleText = createElement("text");
46
- setProp(titleText, "fg", "#00ff00");
47
- setProp(titleText, "bold", true);
48
- insertNode(titleText, createTextNode("TokenWatch Usage Report"));
49
- insertNode(header, titleText);
50
- const hint = createElement("text");
51
- setProp(hint, "fg", "#888888");
52
- insertNode(hint, createTextNode("ESC/Q: Close | Ctrl+P: Commands"));
53
- insertNode(header, hint);
54
- insertNode(container, header);
55
- const divider = createElement("text");
56
- setProp(divider, "fg", "#555555");
57
- insertNode(divider, createTextNode("─".repeat(80)));
58
- insertNode(container, divider);
59
- const scrollbox = createElement("scrollbox");
60
- setProp(scrollbox, "paddingX", 2);
61
- setProp(scrollbox, "paddingY", 1);
62
- setProp(scrollbox, "flex", 1);
63
- const contentBox = createElement("box");
64
- setProp(contentBox, "flexDirection", "column");
65
- for (const line of output.split("\n")) {
66
- const textNode = createElement("text");
67
- setProp(textNode, "wrapMode", "none");
68
- if (line.startsWith("═══") || line.includes("Breakdown") || line.includes("Summary")) {
69
- setProp(textNode, "fg", props.api.theme.current.primary);
70
- setProp(textNode, "bold", true);
71
- }
72
- else if (line.match(/^[┌├└│╔╠╚║]/)) {
73
- setProp(textNode, "fg", props.api.theme.current.textMuted);
74
- }
75
- else {
76
- setProp(textNode, "fg", props.api.theme.current.text);
77
- }
78
- insertNode(textNode, createTextNode(line || " "));
79
- insertNode(contentBox, textNode);
80
- }
81
- insertNode(scrollbox, contentBox);
82
- insertNode(container, scrollbox);
83
- return container;
84
- }
85
- function modelLabel(provider, model) {
86
- const shortModel = model.includes("/") ? model.split("/").pop() ?? model : model;
87
- return provider && provider !== "unknown" ? `${provider}/${shortModel}` : shortModel;
88
- }
89
- function sumSidebar(items) {
90
- return items.reduce((acc, item) => {
91
- acc.modelsUsed.push(modelLabel(item.provider, item.model));
92
- acc.totalTokens += item.total;
93
- acc.inputTokens += item.input;
94
- acc.outputTokens += item.output;
95
- acc.reasoningTokens += item.reasoning;
96
- acc.cacheRead += item.cacheRead;
97
- acc.totalCost += item.cost;
98
- acc.requestCount += item.requests;
99
- return acc;
100
- }, {
101
- model: "",
102
- provider: "",
103
- modelsUsed: [],
104
- totalTokens: 0,
105
- inputTokens: 0,
106
- outputTokens: 0,
107
- reasoningTokens: 0,
108
- cacheRead: 0,
109
- cacheWrite: 0,
110
- totalCost: 0,
111
- requestCount: 0,
112
- });
113
- }
114
- function buildSidebarStats(messages) {
115
- const models = new Map();
116
- for (const msg of messages) {
117
- const info = msg.info ?? msg;
118
- if (info.role !== "assistant" || !info.tokens || !info.tokens.total)
119
- continue;
120
- const provider = info.providerID ?? "unknown";
121
- const model = info.modelID ?? info.model ?? "unknown";
122
- const key = `${provider}::${model}`;
123
- const current = models.get(key) ?? {
124
- provider,
125
- model,
126
- requests: 0,
127
- total: 0,
128
- input: 0,
129
- output: 0,
130
- reasoning: 0,
131
- cacheRead: 0,
132
- cost: 0,
133
- };
134
- current.requests += 1;
135
- current.total += info.tokens.total ?? 0;
136
- current.input += info.tokens.input ?? 0;
137
- current.output += info.tokens.output ?? 0;
138
- current.reasoning += info.tokens.reasoning ?? 0;
139
- current.cacheRead += info.tokens.cache?.read ?? 0;
140
- current.cost += info.cost ?? 0;
141
- models.set(key, current);
142
- }
143
- return [...models.values()].sort((a, b) => b.total - a.total);
144
- }
145
- function joinExportPath(name) {
146
- return path.join(process.cwd(), name);
147
- }
148
- async function promptForPreset(api, stack) {
149
- return new Promise((resolve) => {
150
- stack.replace(() => api.ui.DialogSelect({
151
- title: "TokenWatch: Date Range",
152
- options: [
153
- { title: "All local sessions", value: "all", description: "Read every local session record" },
154
- { title: "Last 7 days", value: "7d", description: "Aggregate recent token usage" },
155
- { title: "Last 30 days", value: "30d", description: "Useful for monthly trend checks" },
156
- { title: "This month", value: "month", description: "From the first day of this month to today" },
157
- ],
158
- onSelect: (option) => {
159
- resolve(option.value);
160
- },
161
- }), () => resolve(undefined));
162
- });
163
- }
164
- async function promptForAction(api, stack) {
165
- return new Promise((resolve) => {
166
- stack.replace(() => api.ui.DialogSelect({
167
- title: "TokenWatch: Action",
168
- options: [
169
- { title: "⚡ Quick View", value: "quick", description: "Show report for all local history immediately" },
170
- { title: "📊 Custom Report", value: "view", description: "Filter by date, provider, or model" },
171
- { title: "💾 Export JSON", value: "export-json", description: "Save a full report to JSON" },
172
- { title: "📄 Export CSV", value: "export-csv", description: "Export one grouped table to CSV" },
173
- ],
174
- onSelect: (option) => {
175
- resolve(option.value);
176
- },
177
- }), () => resolve(undefined));
178
- });
179
- }
180
- async function promptForCsvSection(api, stack) {
181
- return new Promise((resolve) => {
182
- stack.replace(() => api.ui.DialogSelect({
183
- title: "TokenWatch: CSV Section",
184
- options: [
185
- { title: "By model", value: "models", description: "provider + model breakdown" },
186
- { title: "By provider", value: "providers", description: "provider totals" },
187
- { title: "By day", value: "daily", description: "daily trend table" },
188
- { title: "By session", value: "sessions", description: "recent session summary rows" },
189
- ],
190
- onSelect: (option) => {
191
- resolve(option.value);
192
- },
193
- }), () => resolve(undefined));
194
- });
195
- }
196
- async function promptForProvider(api, stack) {
197
- const providers = await getAvailableProviders();
198
- return new Promise((resolve) => {
199
- stack.replace(() => api.ui.DialogSelect({
200
- title: "TokenWatch: Provider Filter",
201
- placeholder: "Filter providers",
202
- options: [
203
- { title: "All providers", value: "", description: "Do not filter by provider" },
204
- ...providers.map((provider) => ({
205
- title: provider,
206
- value: provider,
207
- description: `Only include ${provider}`,
208
- })),
209
- ],
210
- onSelect: (option) => {
211
- resolve(option.value || "");
212
- },
213
- }), () => resolve(undefined));
214
- });
215
- }
216
- async function promptForModel(api, stack) {
217
- const models = await getAvailableModels();
218
- return new Promise((resolve) => {
219
- stack.replace(() => api.ui.DialogSelect({
220
- title: "TokenWatch: Model Filter",
221
- placeholder: "Filter models",
222
- options: [
223
- { title: "All models", value: "", description: "Do not filter by model" },
224
- ...models.map((model) => ({
225
- title: model,
226
- value: model,
227
- description: `Only include ${model}`,
228
- })),
229
- ],
230
- onSelect: (option) => {
231
- resolve(option.value || "");
232
- },
233
- }), () => resolve(undefined));
234
- });
235
- }
236
- async function collectFilters(api, stack) {
237
- const preset = await promptForPreset(api, stack);
238
- if (!preset)
239
- return undefined;
240
- const provider = await promptForProvider(api, stack);
241
- const model = await promptForModel(api, stack);
242
- return {
243
- ...getPresetRange(preset),
244
- provider,
245
- model,
246
- limit: preset === "all" ? 60 : 31,
247
- };
248
- }
249
- const plugin = {
250
- id: "opencode-tokenwatch",
251
- tui: async (api) => {
252
- let lastActiveSessionId;
253
- const [sidebarRevision, setSidebarRevision] = createSignal(0);
254
- const refreshSidebar = (sessionID) => {
255
- if (!sessionID || sessionID === lastActiveSessionId) {
256
- setSidebarRevision((value) => value + 1);
257
- }
258
- };
259
- api.lifecycle.onDispose(api.event.on("message.updated", (event) => {
260
- refreshSidebar(event.properties.sessionID);
261
- }));
262
- api.lifecycle.onDispose(api.event.on("message.removed", (event) => {
263
- refreshSidebar(event.properties.sessionID);
264
- }));
265
- api.route.register([{
266
- name: "tokenwatch-report",
267
- render: ({ params }) => ReportScreen({ api, params: params }),
268
- }]);
269
- api.command?.register(() => {
270
- const current = api.route.current;
271
- const isReport = current && "name" in current && current.name === "tokenwatch-report";
272
- if (!isReport)
273
- return [];
274
- const lastSessionId = current.params?.lastSessionId;
275
- return [{
276
- title: "TokenWatch: Close Report",
277
- value: "tokenwatch-close-report",
278
- description: "Return to chat",
279
- keybind: "escape,q",
280
- onSelect: () => {
281
- if (lastSessionId) {
282
- api.route.navigate("session", { sessionID: lastSessionId });
283
- }
284
- else {
285
- api.route.navigate("home");
286
- }
287
- },
288
- }];
289
- });
290
- api.command?.register(() => [{
291
- title: "TokenWatch: Usage Stats",
292
- value: "tokenwatch-usage",
293
- description: "View or export token usage by model, provider, date, and session",
294
- slash: { name: "usage", aliases: ["tokens", "tokenwatch"] },
295
- onSelect: async (cmdDialog) => {
296
- cmdDialog?.clear();
297
- const stack = api.ui.dialog;
298
- try {
299
- const action = await promptForAction(api, stack);
300
- if (!action)
301
- return;
302
- let filters;
303
- if (action === "quick") {
304
- filters = { ...getPresetRange("all"), limit: 60 };
305
- }
306
- else {
307
- filters = await collectFilters(api, stack);
308
- }
309
- if (!filters)
310
- return;
311
- api.ui.toast({ variant: "info", message: `TokenWatch is reading local history: ${formatFilters(filters)}` });
312
- const report = await getUsageReport(filters);
313
- if (action === "view" || action === "quick") {
314
- stack.clear();
315
- const reportData = {
316
- summary: report.summary,
317
- models: report.models,
318
- providers: report.providers,
319
- daily: report.daily,
320
- sessions: report.sessions,
321
- };
322
- api.route.navigate("tokenwatch-report", {
323
- reportJson: JSON.stringify(reportData),
324
- filtersJson: JSON.stringify(filters),
325
- lastSessionId: lastActiveSessionId,
326
- });
327
- return;
328
- }
329
- if (action === "export-json") {
330
- stack.clear();
331
- const filePath = joinExportPath("tokenwatch-usage-report.json");
332
- fs.writeFileSync(filePath, JSON.stringify(report, null, 2));
333
- api.ui.toast({ variant: "success", message: `Exported JSON to ${filePath}` });
334
- return;
335
- }
336
- const section = await promptForCsvSection(api, stack);
337
- stack.clear();
338
- if (!section)
339
- return;
340
- const filePath = joinExportPath(`tokenwatch-${section}.csv`);
341
- fs.writeFileSync(filePath, exportReportAsCsv(report, section));
342
- api.ui.toast({ variant: "success", message: `Exported CSV to ${filePath}` });
343
- }
344
- catch (error) {
345
- api.ui.toast({ variant: "error", message: `TokenWatch error: ${error.message}` });
346
- }
347
- },
348
- }]);
349
- api.slots.register({
350
- order: 100,
351
- slots: {
352
- sidebar_content: (_ctx, { session_id }) => {
353
- sidebarRevision();
354
- lastActiveSessionId = session_id;
355
- const messages = api.state.session.messages(session_id);
356
- const stats = buildSidebarStats(messages);
357
- const total = sumSidebar(stats);
358
- const container = createElement("box");
359
- setProp(container, "flexDirection", "column");
360
- setProp(container, "marginTop", 1);
361
- const headerText = createElement("text");
362
- setProp(headerText, "fg", _ctx.theme.current.primary);
363
- insertNode(headerText, createTextNode("TokenWatch"));
364
- insertNode(container, headerText);
365
- if (stats.length === 0) {
366
- const noData = createElement("text");
367
- setProp(noData, "fg", _ctx.theme.current.textMuted);
368
- insertNode(noData, createTextNode(" No assistant data yet"));
369
- insertNode(container, noData);
370
- return container;
371
- }
372
- for (const item of stats) {
373
- const main = createElement("text");
374
- setProp(main, "fg", _ctx.theme.current.primary);
375
- insertNode(main, createTextNode(` ${modelLabel(item.provider, item.model)}`));
376
- insertNode(container, main);
377
- const detail = createElement("text");
378
- setProp(detail, "fg", _ctx.theme.current.textMuted);
379
- insertNode(detail, createTextNode(` ${formatTokens(item.total)} (in:${formatTokens(item.input)} out:${formatTokens(item.output)})`));
380
- insertNode(container, detail);
381
- const subDetail = createElement("text");
382
- setProp(subDetail, "fg", _ctx.theme.current.textMuted);
383
- insertNode(subDetail, createTextNode(` req:${item.requests} cache:${formatTokens(item.cacheRead)}`));
384
- insertNode(container, subDetail);
385
- }
386
- if (stats.length > 1) {
387
- const divider = createElement("text");
388
- setProp(divider, "fg", _ctx.theme.current.textMuted);
389
- insertNode(divider, createTextNode(" ───────────────────────────────"));
390
- insertNode(container, divider);
391
- const totalLine = createElement("text");
392
- setProp(totalLine, "fg", _ctx.theme.current.textMuted);
393
- insertNode(totalLine, createTextNode(` Total: ${formatTokens(total.totalTokens)} req:${total.requestCount}`));
394
- insertNode(container, totalLine);
395
- const costLine = createElement("text");
396
- setProp(costLine, "fg", _ctx.theme.current.textMuted);
397
- insertNode(costLine, createTextNode(` in:${formatTokens(total.inputTokens)} out:${formatTokens(total.outputTokens)} cache:${formatTokens(total.cacheRead)}`));
398
- insertNode(container, costLine);
399
- }
400
- return container;
401
- },
402
- },
403
- });
404
- },
405
- };
406
- export default plugin;