opencode-tokenwatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TTWK
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # opencode-tokenwatch
2
+
3
+ Token usage analytics plugin for OpenCode.
4
+
5
+ It adds a live sidebar panel for the current session and a `/usage` slash command
6
+ that reads your local OpenCode history from SQLite, aggregates token usage by
7
+ model, provider, date, and session, and supports export to JSON or CSV.
8
+
9
+ ## Features
10
+
11
+ - Live sidebar updates while the assistant replies
12
+ - Current-session aggregation across every model used in the conversation
13
+ - `/usage` report for local history with model, provider, date, and session views
14
+ - Export full reports to `JSON`
15
+ - Export grouped tables to `CSV`
16
+ - Uses `opencode db ... --format json` directly, so it works from local records
17
+
18
+ ## Install
19
+
20
+ Add the package to OpenCode's plugin list.
21
+
22
+ Install the package first:
23
+
24
+ ```sh
25
+ npm install opencode-tokenwatch
26
+ ```
27
+
28
+ `opencode.json` or `opencode.jsonc`
29
+
30
+ ```json
31
+ {
32
+ "$schema": "https://opencode.ai/config.json",
33
+ "plugin": ["opencode-tokenwatch"]
34
+ }
35
+ ```
36
+
37
+ `tui.json` or `tui.jsonc`
38
+
39
+ ```json
40
+ {
41
+ "$schema": "https://opencode.ai/tui.json",
42
+ "plugin": ["opencode-tokenwatch"]
43
+ }
44
+ ```
45
+
46
+ If you are preparing for npm publishing, this package is already configured to
47
+ publish public artifacts from `dist/`.
48
+
49
+ ## Usage
50
+
51
+ In OpenCode TUI:
52
+
53
+ 1. Open the right sidebar to see live token totals for the current session.
54
+ 2. Run `/usage`.
55
+ 3. Choose:
56
+ - `View report`
57
+ - `Export JSON`
58
+ - `Export CSV`
59
+ 4. Pick a time range and optional provider/model filters.
60
+
61
+ Exports are written to the current working directory:
62
+
63
+ - `tokenwatch-usage-report.json`
64
+ - `tokenwatch-models.csv`
65
+ - `tokenwatch-providers.csv`
66
+ - `tokenwatch-daily.csv`
67
+ - `tokenwatch-sessions.csv`
68
+
69
+ ## Report Dimensions
70
+
71
+ - Model
72
+ - Provider
73
+ - Day
74
+ - Session
75
+ - Current session summary
76
+
77
+ ## Requirements
78
+
79
+ - OpenCode CLI with `opencode db`
80
+ - Node.js 18+
81
+
82
+ ## Build
83
+
84
+ ```sh
85
+ npm install
86
+ npm run build
87
+ ```
88
+
89
+ ## Publish Prep
90
+
91
+ Before publishing to npm, run:
92
+
93
+ ```sh
94
+ npm run release:check
95
+ ```
96
+
97
+ This builds the package and runs `npm pack --dry-run` with an isolated temp npm
98
+ cache, which is especially helpful on Windows when the default cache directory is
99
+ locked by another process.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,87 @@
1
+ export interface UsageFilters {
2
+ sessionId?: string;
3
+ model?: string;
4
+ provider?: string;
5
+ startDate?: string;
6
+ endDate?: string;
7
+ limit?: number;
8
+ }
9
+ export interface SessionTokenData {
10
+ model: string;
11
+ provider: string;
12
+ modelsUsed: string[];
13
+ totalTokens: number;
14
+ inputTokens: number;
15
+ outputTokens: number;
16
+ reasoningTokens: number;
17
+ cacheRead: number;
18
+ cacheWrite: number;
19
+ totalCost: number;
20
+ requestCount: number;
21
+ }
22
+ export interface ModelBreakdownItem {
23
+ provider: string;
24
+ model: string;
25
+ requests: number;
26
+ sessions: number;
27
+ totalTokens: number;
28
+ inputTokens: number;
29
+ outputTokens: number;
30
+ reasoningTokens: number;
31
+ cacheRead: number;
32
+ totalCost: number;
33
+ }
34
+ export interface ProviderBreakdownItem {
35
+ provider: string;
36
+ requests: number;
37
+ sessions: number;
38
+ totalTokens: number;
39
+ inputTokens: number;
40
+ outputTokens: number;
41
+ reasoningTokens: number;
42
+ cacheRead: number;
43
+ totalCost: number;
44
+ }
45
+ export interface DailyBreakdownItem {
46
+ day: string;
47
+ requests: number;
48
+ sessions: number;
49
+ totalTokens: number;
50
+ inputTokens: number;
51
+ outputTokens: number;
52
+ reasoningTokens: number;
53
+ cacheRead: number;
54
+ totalCost: number;
55
+ }
56
+ export interface SessionBreakdownItem {
57
+ sessionId: string;
58
+ title: string;
59
+ provider: string;
60
+ model: string;
61
+ requests: number;
62
+ totalTokens: number;
63
+ inputTokens: number;
64
+ outputTokens: number;
65
+ reasoningTokens: number;
66
+ cacheRead: number;
67
+ totalCost: number;
68
+ day: string;
69
+ }
70
+ export interface UsageReport {
71
+ filters: UsageFilters;
72
+ summary: SessionTokenData;
73
+ models: ModelBreakdownItem[];
74
+ providers: ProviderBreakdownItem[];
75
+ daily: DailyBreakdownItem[];
76
+ sessions: SessionBreakdownItem[];
77
+ }
78
+ export declare function formatTokens(n: number): string;
79
+ export declare function formatCost(n: number): string;
80
+ export declare function formatFilters(filters: UsageFilters): string;
81
+ export declare function formatSessionSummary(data: SessionTokenData, title?: string): string;
82
+ export declare function formatStatusBar(data: SessionTokenData): string;
83
+ export declare function formatModelBreakdown(items: ModelBreakdownItem[]): string;
84
+ export declare function formatProviderBreakdown(items: ProviderBreakdownItem[]): string;
85
+ export declare function formatDailyBreakdown(items: DailyBreakdownItem[]): string;
86
+ export declare function formatSessionBreakdown(items: SessionBreakdownItem[]): string;
87
+ export declare function formatUsageReport(report: UsageReport): string;
@@ -0,0 +1,278 @@
1
+ export function formatTokens(n) {
2
+ if (n >= 1_000_000_000)
3
+ return `${(n / 1_000_000_000).toFixed(1)}B`;
4
+ if (n >= 1_000_000)
5
+ return `${(n / 1_000_000).toFixed(1)}M`;
6
+ if (n >= 1_000)
7
+ return `${(n / 1_000).toFixed(1)}K`;
8
+ return String(n);
9
+ }
10
+ export function formatCost(n) {
11
+ if (n === 0)
12
+ return "$0.00";
13
+ if (n < 0.01)
14
+ return `$${n.toFixed(4)}`;
15
+ return `$${n.toFixed(2)}`;
16
+ }
17
+ export function formatFilters(filters) {
18
+ const parts = [];
19
+ if (filters.sessionId)
20
+ parts.push(`session=${filters.sessionId}`);
21
+ if (filters.provider)
22
+ parts.push(`provider=${filters.provider}`);
23
+ if (filters.model)
24
+ parts.push(`model=${filters.model}`);
25
+ if (filters.startDate || filters.endDate) {
26
+ parts.push(`date=${filters.startDate ?? "..."}..${filters.endDate ?? "..."}`);
27
+ }
28
+ return parts.length ? parts.join(" | ") : "scope=all local sessions";
29
+ }
30
+ function getVisualWidth(str) {
31
+ let width = 0;
32
+ for (let i = 0; i < str.length; i++) {
33
+ width += str.charCodeAt(i) > 255 ? 2 : 1;
34
+ }
35
+ return width;
36
+ }
37
+ function truncateByWidth(str, maxWidth) {
38
+ if (getVisualWidth(str) <= maxWidth)
39
+ return str;
40
+ let width = 0;
41
+ let res = "";
42
+ for (let i = 0; i < str.length; i++) {
43
+ const charWidth = str.charCodeAt(i) > 255 ? 2 : 1;
44
+ if (width + charWidth > maxWidth - 3) {
45
+ return res + "...";
46
+ }
47
+ width += charWidth;
48
+ res += str[i];
49
+ }
50
+ return res;
51
+ }
52
+ function table(columns, rows, totalRow) {
53
+ const widths = columns.map((col, i) => {
54
+ const rowWidths = rows.map((row) => getVisualWidth(row[i] ?? ""));
55
+ const maxRow = rowWidths.length ? Math.max(...rowWidths) : 0;
56
+ const totalWidth = totalRow ? getVisualWidth(totalRow[i] ?? "") : 0;
57
+ return Math.max(getVisualWidth(col.label), maxRow, totalWidth);
58
+ });
59
+ const renderRow = (cells) => "║" + cells.map((cell, i) => {
60
+ const value = cell ?? "";
61
+ const vWidth = getVisualWidth(value);
62
+ const padding = " ".repeat(widths[i] - vWidth);
63
+ const content = columns[i].align === "right"
64
+ ? padding + value
65
+ : value + padding;
66
+ return ` ${content} ║`;
67
+ }).join("");
68
+ const sep = (left, mid, right, fill) => left + widths.map((w) => fill.repeat(w + 2)).join(mid) + right;
69
+ const lines = [
70
+ sep("╔", "╦", "╗", "═"),
71
+ renderRow(columns.map((col) => col.label)),
72
+ sep("╠", "╬", "╣", "═"),
73
+ ...rows.map(renderRow),
74
+ ];
75
+ if (totalRow) {
76
+ lines.push(sep("╠", "╬", "╣", "═"));
77
+ lines.push(renderRow(totalRow));
78
+ }
79
+ lines.push(sep("╚", "╩", "╝", "═"));
80
+ return lines.join("\n");
81
+ }
82
+ export function formatSessionSummary(data, title = "Current Session") {
83
+ const modelLabel = data.modelsUsed.length > 1
84
+ ? `${data.modelsUsed.length} models`
85
+ : data.model || "(unknown)";
86
+ return [
87
+ `═══ ${title} ═══`,
88
+ `Models: ${modelLabel}`,
89
+ `Provider: ${data.provider || "(mixed)"}`,
90
+ `Requests: ${data.requestCount}`,
91
+ `Total Tokens: ${formatTokens(data.totalTokens)}`,
92
+ ` Input: ${formatTokens(data.inputTokens)}`,
93
+ ` Output: ${formatTokens(data.outputTokens)}`,
94
+ ` Reasoning: ${formatTokens(data.reasoningTokens)}`,
95
+ ` Cache Read: ${formatTokens(data.cacheRead)}`,
96
+ ` Cache Write: ${formatTokens(data.cacheWrite)}`,
97
+ ` Cost: ${formatCost(data.totalCost)}`,
98
+ ].join("\n");
99
+ }
100
+ export function formatStatusBar(data) {
101
+ return `Tok:${formatTokens(data.totalTokens)} Req:${data.requestCount} Cost:${formatCost(data.totalCost)}`;
102
+ }
103
+ export function formatModelBreakdown(items) {
104
+ if (items.length === 0)
105
+ return "═══ Model Breakdown ═══\n(no data)";
106
+ const total = items.reduce((acc, item) => ({
107
+ requests: acc.requests + item.requests,
108
+ totalTokens: acc.totalTokens + item.totalTokens,
109
+ inputTokens: acc.inputTokens + item.inputTokens,
110
+ outputTokens: acc.outputTokens + item.outputTokens,
111
+ cacheRead: acc.cacheRead + item.cacheRead,
112
+ }), {
113
+ requests: 0,
114
+ totalTokens: 0,
115
+ inputTokens: 0,
116
+ outputTokens: 0,
117
+ cacheRead: 0,
118
+ });
119
+ const rows = items.map((item) => [
120
+ item.provider || "-",
121
+ truncateByWidth(item.model, 20),
122
+ String(item.requests),
123
+ formatTokens(item.totalTokens),
124
+ formatTokens(item.inputTokens),
125
+ formatTokens(item.outputTokens),
126
+ formatTokens(item.cacheRead),
127
+ ]);
128
+ return [
129
+ "═══ Model Breakdown ═══",
130
+ table([
131
+ { label: "Provider", align: "left" },
132
+ { label: "Model", align: "left" },
133
+ { label: "Req", align: "right" },
134
+ { label: "Total", align: "right" },
135
+ { label: "In", align: "right" },
136
+ { label: "Out", align: "right" },
137
+ { label: "Cache", align: "right" },
138
+ ], rows, [
139
+ "TOTAL",
140
+ "",
141
+ String(total.requests),
142
+ formatTokens(total.totalTokens),
143
+ formatTokens(total.inputTokens),
144
+ formatTokens(total.outputTokens),
145
+ formatTokens(total.cacheRead),
146
+ ]),
147
+ ].join("\n");
148
+ }
149
+ export function formatProviderBreakdown(items) {
150
+ if (items.length === 0)
151
+ return "═══ Provider Breakdown ═══\n(no data)";
152
+ const total = items.reduce((acc, item) => ({
153
+ requests: acc.requests + item.requests,
154
+ totalTokens: acc.totalTokens + item.totalTokens,
155
+ inputTokens: acc.inputTokens + item.inputTokens,
156
+ outputTokens: acc.outputTokens + item.outputTokens,
157
+ cacheRead: acc.cacheRead + item.cacheRead,
158
+ }), {
159
+ requests: 0,
160
+ totalTokens: 0,
161
+ inputTokens: 0,
162
+ outputTokens: 0,
163
+ cacheRead: 0,
164
+ });
165
+ const rows = items.map((item) => [
166
+ item.provider || "-",
167
+ String(item.requests),
168
+ formatTokens(item.totalTokens),
169
+ formatTokens(item.inputTokens),
170
+ formatTokens(item.outputTokens),
171
+ formatTokens(item.cacheRead),
172
+ ]);
173
+ return [
174
+ "═══ Provider Breakdown ═══",
175
+ table([
176
+ { label: "Provider", align: "left" },
177
+ { label: "Req", align: "right" },
178
+ { label: "Total", align: "right" },
179
+ { label: "In", align: "right" },
180
+ { label: "Out", align: "right" },
181
+ { label: "Cache", align: "right" },
182
+ ], rows, [
183
+ "TOTAL",
184
+ String(total.requests),
185
+ formatTokens(total.totalTokens),
186
+ formatTokens(total.inputTokens),
187
+ formatTokens(total.outputTokens),
188
+ formatTokens(total.cacheRead),
189
+ ]),
190
+ ].join("\n");
191
+ }
192
+ export function formatDailyBreakdown(items) {
193
+ if (items.length === 0)
194
+ return "═══ Daily Breakdown ═══\n(no data)";
195
+ const total = items.reduce((acc, item) => ({
196
+ requests: acc.requests + item.requests,
197
+ totalTokens: acc.totalTokens + item.totalTokens,
198
+ inputTokens: acc.inputTokens + item.inputTokens,
199
+ outputTokens: acc.outputTokens + item.outputTokens,
200
+ cacheRead: acc.cacheRead + item.cacheRead,
201
+ }), {
202
+ requests: 0,
203
+ totalTokens: 0,
204
+ inputTokens: 0,
205
+ outputTokens: 0,
206
+ cacheRead: 0,
207
+ });
208
+ const rows = items.map((item) => [
209
+ item.day,
210
+ String(item.requests),
211
+ formatTokens(item.totalTokens),
212
+ formatTokens(item.inputTokens),
213
+ formatTokens(item.outputTokens),
214
+ formatTokens(item.cacheRead),
215
+ ]);
216
+ return [
217
+ "═══ Daily Breakdown ═══",
218
+ table([
219
+ { label: "Day", align: "left" },
220
+ { label: "Req", align: "right" },
221
+ { label: "Total", align: "right" },
222
+ { label: "In", align: "right" },
223
+ { label: "Out", align: "right" },
224
+ { label: "Cache", align: "right" },
225
+ ], rows, [
226
+ "TOTAL",
227
+ String(total.requests),
228
+ formatTokens(total.totalTokens),
229
+ formatTokens(total.inputTokens),
230
+ formatTokens(total.outputTokens),
231
+ formatTokens(total.cacheRead),
232
+ ]),
233
+ ].join("\n");
234
+ }
235
+ export function formatSessionBreakdown(items) {
236
+ if (items.length === 0)
237
+ return "═══ Session Breakdown ═══\n(no data)";
238
+ const rows = items.map((item) => {
239
+ // Truncate title by visual width (40 columns)
240
+ const title = truncateByWidth(item.title, 40);
241
+ return [
242
+ item.day,
243
+ item.provider || "-",
244
+ truncateByWidth(item.model, 20),
245
+ String(item.requests),
246
+ formatTokens(item.totalTokens),
247
+ formatTokens(item.cacheRead),
248
+ title,
249
+ ];
250
+ });
251
+ return [
252
+ "═══ Session Breakdown ═══",
253
+ table([
254
+ { label: "Day", align: "left" },
255
+ { label: "Provider", align: "left" },
256
+ { label: "Model", align: "left" },
257
+ { label: "Req", align: "right" },
258
+ { label: "Total", align: "right" },
259
+ { label: "Cache", align: "right" },
260
+ { label: "Title", align: "left" },
261
+ ], rows),
262
+ ].join("\n");
263
+ }
264
+ export function formatUsageReport(report) {
265
+ return [
266
+ `Filters: ${formatFilters(report.filters)}`,
267
+ "",
268
+ formatSessionSummary(report.summary, "Usage Summary"),
269
+ "",
270
+ formatModelBreakdown(report.models),
271
+ "",
272
+ formatProviderBreakdown(report.providers),
273
+ "",
274
+ formatDailyBreakdown(report.daily),
275
+ "",
276
+ formatSessionBreakdown(report.sessions),
277
+ ].join("\n");
278
+ }
@@ -0,0 +1,5 @@
1
+ import type { PluginModule } from "@opencode-ai/plugin";
2
+ declare const plugin: PluginModule & {
3
+ id: string;
4
+ };
5
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const plugin = {
2
+ id: "opencode-tokenwatch",
3
+ server: async () => {
4
+ return {};
5
+ },
6
+ };
7
+ export default plugin;
@@ -0,0 +1,12 @@
1
+ import type { DailyBreakdownItem, ModelBreakdownItem, ProviderBreakdownItem, SessionBreakdownItem, SessionTokenData, UsageFilters, UsageReport } from "./formatter.js";
2
+ export declare function getPresetRange(preset: "all" | "7d" | "30d" | "month"): Pick<UsageFilters, "startDate" | "endDate">;
3
+ export declare function getCurrentSessionStats(sessionId?: string): Promise<SessionTokenData>;
4
+ export declare function getSummary(filters?: UsageFilters): Promise<SessionTokenData>;
5
+ export declare function getModelBreakdown(filters?: UsageFilters): Promise<ModelBreakdownItem[]>;
6
+ export declare function getProviderBreakdown(filters?: UsageFilters): Promise<ProviderBreakdownItem[]>;
7
+ export declare function getDailyBreakdown(filters?: UsageFilters): Promise<DailyBreakdownItem[]>;
8
+ export declare function getSessionBreakdown(filters?: UsageFilters): Promise<SessionBreakdownItem[]>;
9
+ export declare function getAvailableModels(): Promise<string[]>;
10
+ export declare function getAvailableProviders(): Promise<string[]>;
11
+ export declare function getUsageReport(filters?: UsageFilters): Promise<UsageReport>;
12
+ export declare function exportReportAsCsv(report: UsageReport, section: "models" | "providers" | "daily" | "sessions"): string;
@@ -0,0 +1,334 @@
1
+ import { exec } from "node:child_process";
2
+ function execAsync(cmd) {
3
+ return new Promise((resolve, reject) => {
4
+ exec(cmd, { windowsHide: true, timeout: 30000 }, (error, stdout, stderr) => {
5
+ if (error)
6
+ reject(Object.assign(error, { stderr }));
7
+ else
8
+ resolve({ stdout, stderr });
9
+ });
10
+ });
11
+ }
12
+ async function queryDb(sql) {
13
+ const flatSql = sql.replace(/\s+/g, " ").trim();
14
+ const { stdout, stderr } = await execAsync(`opencode db ${JSON.stringify(flatSql)} --format json`);
15
+ if (stderr)
16
+ throw new Error(stderr.trim());
17
+ const parsed = JSON.parse(stdout.trim());
18
+ return Array.isArray(parsed) ? parsed : parsed.data ?? [];
19
+ }
20
+ function escapeSql(value) {
21
+ return value.replace(/'/g, "''");
22
+ }
23
+ function messageWhere(filters) {
24
+ const where = [
25
+ "json_extract(m.data, '$.role') = 'assistant'",
26
+ "coalesce(json_extract(m.data, '$.tokens.total'), 0) > 0",
27
+ ];
28
+ if (filters.sessionId)
29
+ where.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
30
+ if (filters.provider)
31
+ where.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
32
+ if (filters.model)
33
+ where.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
34
+ if (filters.startDate)
35
+ where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${escapeSql(filters.startDate)}'`);
36
+ if (filters.endDate)
37
+ where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${escapeSql(filters.endDate)}'`);
38
+ return where.join(" AND ");
39
+ }
40
+ function parseList(value) {
41
+ if (!value)
42
+ return [];
43
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
44
+ }
45
+ function toSessionTokenData(row) {
46
+ const models = parseList(row?.models_used);
47
+ const providers = parseList(row?.providers_used);
48
+ return {
49
+ model: models.length === 1 ? models[0] : "",
50
+ provider: providers.length === 1 ? providers[0] : "",
51
+ modelsUsed: models,
52
+ totalTokens: row?.total_tokens ?? 0,
53
+ inputTokens: row?.input_tokens ?? 0,
54
+ outputTokens: row?.output_tokens ?? 0,
55
+ reasoningTokens: row?.reasoning_tokens ?? 0,
56
+ cacheRead: row?.cache_read ?? 0,
57
+ cacheWrite: row?.cache_write ?? 0,
58
+ totalCost: row?.total_cost ?? 0,
59
+ requestCount: row?.request_count ?? 0,
60
+ };
61
+ }
62
+ export function getPresetRange(preset) {
63
+ if (preset === "all")
64
+ return {};
65
+ const end = new Date();
66
+ const start = new Date(end);
67
+ if (preset === "7d")
68
+ start.setDate(end.getDate() - 6);
69
+ if (preset === "30d")
70
+ start.setDate(end.getDate() - 29);
71
+ if (preset === "month")
72
+ start.setDate(1);
73
+ const format = (date) => {
74
+ const year = date.getFullYear();
75
+ const month = String(date.getMonth() + 1).padStart(2, "0");
76
+ const day = String(date.getDate()).padStart(2, "0");
77
+ return `${year}-${month}-${day}`;
78
+ };
79
+ return { startDate: format(start), endDate: format(end) };
80
+ }
81
+ export async function getCurrentSessionStats(sessionId) {
82
+ const filters = sessionId ? { sessionId } : {};
83
+ return getSummary(filters);
84
+ }
85
+ export async function getSummary(filters = {}) {
86
+ const sql = `
87
+ SELECT
88
+ group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
89
+ group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
90
+ count(*) as request_count,
91
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
92
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
93
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
94
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
95
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
96
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
97
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
98
+ FROM message m
99
+ WHERE ${messageWhere(filters)}
100
+ `.trim();
101
+ const rows = await queryDb(sql);
102
+ return toSessionTokenData(rows[0]);
103
+ }
104
+ export async function getModelBreakdown(filters = {}) {
105
+ const sql = `
106
+ SELECT
107
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
108
+ coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
109
+ count(*) as requests,
110
+ count(distinct m.session_id) as sessions,
111
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
112
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
113
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
114
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
115
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
116
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
117
+ FROM message m
118
+ WHERE ${messageWhere(filters)}
119
+ GROUP BY provider, model
120
+ ORDER BY total_tokens DESC
121
+ `.trim();
122
+ const rows = await queryDb(sql);
123
+ return rows.map((row) => ({
124
+ provider: row.provider ?? "unknown",
125
+ model: row.model ?? "unknown",
126
+ requests: row.requests ?? 0,
127
+ sessions: row.sessions ?? 0,
128
+ totalTokens: row.total_tokens ?? 0,
129
+ inputTokens: row.input_tokens ?? 0,
130
+ outputTokens: row.output_tokens ?? 0,
131
+ reasoningTokens: row.reasoning_tokens ?? 0,
132
+ cacheRead: row.cache_read ?? 0,
133
+ totalCost: row.total_cost ?? 0,
134
+ }));
135
+ }
136
+ export async function getProviderBreakdown(filters = {}) {
137
+ const sql = `
138
+ SELECT
139
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
140
+ count(*) as requests,
141
+ count(distinct m.session_id) as sessions,
142
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
143
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
144
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
145
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
146
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
147
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
148
+ FROM message m
149
+ WHERE ${messageWhere(filters)}
150
+ GROUP BY provider
151
+ ORDER BY total_tokens DESC
152
+ `.trim();
153
+ const rows = await queryDb(sql);
154
+ return rows.map((row) => ({
155
+ provider: row.provider ?? "unknown",
156
+ requests: row.requests ?? 0,
157
+ sessions: row.sessions ?? 0,
158
+ totalTokens: row.total_tokens ?? 0,
159
+ inputTokens: row.input_tokens ?? 0,
160
+ outputTokens: row.output_tokens ?? 0,
161
+ reasoningTokens: row.reasoning_tokens ?? 0,
162
+ cacheRead: row.cache_read ?? 0,
163
+ totalCost: row.total_cost ?? 0,
164
+ }));
165
+ }
166
+ export async function getDailyBreakdown(filters = {}) {
167
+ const limit = filters.limit ?? 30;
168
+ const sql = `
169
+ SELECT
170
+ date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
171
+ count(*) as requests,
172
+ count(distinct m.session_id) as sessions,
173
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
174
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
175
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
176
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
177
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
178
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
179
+ FROM message m
180
+ WHERE ${messageWhere(filters)}
181
+ GROUP BY day
182
+ ORDER BY day DESC
183
+ LIMIT ${Math.max(1, limit)}
184
+ `.trim();
185
+ const rows = await queryDb(sql);
186
+ return rows.map((row) => ({
187
+ day: row.day ?? "",
188
+ requests: row.requests ?? 0,
189
+ sessions: row.sessions ?? 0,
190
+ totalTokens: row.total_tokens ?? 0,
191
+ inputTokens: row.input_tokens ?? 0,
192
+ outputTokens: row.output_tokens ?? 0,
193
+ reasoningTokens: row.reasoning_tokens ?? 0,
194
+ cacheRead: row.cache_read ?? 0,
195
+ totalCost: row.total_cost ?? 0,
196
+ }));
197
+ }
198
+ export async function getSessionBreakdown(filters = {}) {
199
+ const limit = filters.limit ?? 15;
200
+ const sql = `
201
+ SELECT
202
+ s.id as session_id,
203
+ s.title as title,
204
+ coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
205
+ coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
206
+ count(*) as requests,
207
+ sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
208
+ sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
209
+ sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
210
+ sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
211
+ sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
212
+ sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
213
+ date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
214
+ FROM message m
215
+ JOIN session s ON s.id = m.session_id
216
+ WHERE ${messageWhere(filters)}
217
+ GROUP BY s.id, s.title, provider, model
218
+ ORDER BY max(m.time_created) DESC
219
+ LIMIT ${Math.max(1, limit)}
220
+ `.trim();
221
+ const rows = await queryDb(sql);
222
+ return rows.map((row) => ({
223
+ sessionId: row.session_id ?? "",
224
+ title: row.title ?? "(untitled)",
225
+ provider: row.provider ?? "unknown",
226
+ model: row.model ?? "unknown",
227
+ requests: row.requests ?? 0,
228
+ totalTokens: row.total_tokens ?? 0,
229
+ inputTokens: row.input_tokens ?? 0,
230
+ outputTokens: row.output_tokens ?? 0,
231
+ reasoningTokens: row.reasoning_tokens ?? 0,
232
+ cacheRead: row.cache_read ?? 0,
233
+ totalCost: row.total_cost ?? 0,
234
+ day: row.day ?? "",
235
+ }));
236
+ }
237
+ export async function getAvailableModels() {
238
+ const sql = `
239
+ SELECT distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown') as value
240
+ FROM message m
241
+ WHERE ${messageWhere({})}
242
+ ORDER BY value ASC
243
+ `.trim();
244
+ const rows = await queryDb(sql);
245
+ return rows.map((row) => row.value ?? "unknown");
246
+ }
247
+ export async function getAvailableProviders() {
248
+ const sql = `
249
+ SELECT distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown') as value
250
+ FROM message m
251
+ WHERE ${messageWhere({})}
252
+ ORDER BY value ASC
253
+ `.trim();
254
+ const rows = await queryDb(sql);
255
+ return rows.map((row) => row.value ?? "unknown");
256
+ }
257
+ export async function getUsageReport(filters = {}) {
258
+ const [summary, models, providers, daily, sessions] = await Promise.all([
259
+ getSummary(filters),
260
+ getModelBreakdown(filters),
261
+ getProviderBreakdown(filters),
262
+ getDailyBreakdown(filters),
263
+ getSessionBreakdown(filters),
264
+ ]);
265
+ return { filters, summary, models, providers, daily, sessions };
266
+ }
267
+ function csvEscape(value) {
268
+ const text = String(value);
269
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
270
+ }
271
+ export function exportReportAsCsv(report, section) {
272
+ if (section === "models") {
273
+ const header = ["provider", "model", "requests", "sessions", "totalTokens", "inputTokens", "outputTokens", "reasoningTokens", "cacheRead", "totalCost"];
274
+ const rows = report.models.map((item) => [
275
+ item.provider,
276
+ item.model,
277
+ item.requests,
278
+ item.sessions,
279
+ item.totalTokens,
280
+ item.inputTokens,
281
+ item.outputTokens,
282
+ item.reasoningTokens,
283
+ item.cacheRead,
284
+ item.totalCost,
285
+ ]);
286
+ return [header, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
287
+ }
288
+ if (section === "providers") {
289
+ const header = ["provider", "requests", "sessions", "totalTokens", "inputTokens", "outputTokens", "reasoningTokens", "cacheRead", "totalCost"];
290
+ const rows = report.providers.map((item) => [
291
+ item.provider,
292
+ item.requests,
293
+ item.sessions,
294
+ item.totalTokens,
295
+ item.inputTokens,
296
+ item.outputTokens,
297
+ item.reasoningTokens,
298
+ item.cacheRead,
299
+ item.totalCost,
300
+ ]);
301
+ return [header, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
302
+ }
303
+ if (section === "daily") {
304
+ const header = ["day", "requests", "sessions", "totalTokens", "inputTokens", "outputTokens", "reasoningTokens", "cacheRead", "totalCost"];
305
+ const rows = report.daily.map((item) => [
306
+ item.day,
307
+ item.requests,
308
+ item.sessions,
309
+ item.totalTokens,
310
+ item.inputTokens,
311
+ item.outputTokens,
312
+ item.reasoningTokens,
313
+ item.cacheRead,
314
+ item.totalCost,
315
+ ]);
316
+ return [header, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
317
+ }
318
+ const header = ["day", "sessionId", "title", "provider", "model", "requests", "totalTokens", "inputTokens", "outputTokens", "reasoningTokens", "cacheRead", "totalCost"];
319
+ const rows = report.sessions.map((item) => [
320
+ item.day,
321
+ item.sessionId,
322
+ item.title,
323
+ item.provider,
324
+ item.model,
325
+ item.requests,
326
+ item.totalTokens,
327
+ item.inputTokens,
328
+ item.outputTokens,
329
+ item.reasoningTokens,
330
+ item.cacheRead,
331
+ item.totalCost,
332
+ ]);
333
+ return [header, ...rows].map((row) => row.map(csvEscape).join(",")).join("\n");
334
+ }
package/dist/tui.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
2
+ declare const plugin: TuiPluginModule;
3
+ export default plugin;
package/dist/tui.js ADDED
@@ -0,0 +1,406 @@
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;
package/package.json ADDED
@@ -0,0 +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
+ }