opencode-tokscale 0.0.1-rc.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/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # opencode-tokscale
2
+
3
+ A [opencode](https://opencode.ai) TUI sidebar plugin for [tokscale](https://github.com/junhoyeo/tokscale). Shows token usage and costs for today, this week, and this month.
4
+
5
+ ```
6
+ Tokscale
7
+ Today 1.2M $3.45
8
+ This Week 5.6M $12.34
9
+ This Month 12.3M $45.67
10
+ ```
11
+
12
+ ## Install
13
+
14
+ ### Prerequisites
15
+
16
+ [tokscale](https://github.com/junhoyeo/tokscale) must be installed:
17
+
18
+ ```bash
19
+ npm i -g @tokscale/cli
20
+ ```
21
+
22
+ If tokscale isn't found, the plugin shows an install prompt instead of stats.
23
+
24
+ ### Setup
25
+
26
+ One config file. Restart. Done.
27
+
28
+ **`~/.config/opencode/tui.json`**
29
+
30
+ ```json
31
+ {
32
+ "$schema": "https://opencode.ai/tui.json",
33
+ "plugin": [["opencode-tokscale", { "enabled": true }]]
34
+ }
35
+ ```
36
+
37
+ opencode resolves the npm package on startup automatically.
38
+
39
+ ### Options
40
+
41
+ ```json
42
+ {
43
+ "plugin": [["opencode-tokscale", {
44
+ "enabled": true,
45
+ "refreshInterval": 60,
46
+ "showOpenCodeOnly": true
47
+ }]]
48
+ }
49
+ ```
50
+
51
+ | Option | Default | Description |
52
+ |---|---|---|
53
+ | `refreshInterval` | `60` | Seconds between data refreshes |
54
+ | `showOpenCodeOnly` | `true` | Show only opencode usage (`--opencode` flag). Set `false` for all clients |
55
+
56
+ ## How It Works
57
+
58
+ Shells out to `tokscale models --json` with `--today`, `--week`, and `--month` flags. Parses the JSON. Renders totals in the sidebar.
59
+
60
+ ```
61
+ setInterval(60s) → tokscale models --json --today --opencode --no-spinner → parse → render
62
+ → tokscale models --json --week --opencode --no-spinner → parse → render
63
+ → tokscale models --json --month --opencode --no-spinner → parse → render
64
+ ```
65
+
66
+ Three parallel CLI calls per refresh. tokscale processes in ~175ms thanks to its Rust core, so the sidebar stays snappy.
67
+
68
+ ## Features
69
+
70
+ | | What | Why it matters |
71
+ |:---:|---|---|
72
+ | ⏱ | **Auto-refresh** | Configurable interval, default 60 seconds |
73
+ | 🛡 | **Graceful fallback** | No tokscale? Shows install instructions instead of crashing |
74
+
75
+ ## Requirements
76
+
77
+ - [opencode](https://opencode.ai) with plugin support (`@opencode-ai/plugin` >= 1.4.3)
78
+ - [tokscale](https://github.com/junhoyeo/tokscale) CLI installed and in PATH
79
+
80
+ ## Manual Install
81
+
82
+ Skip npm. Copy the source files directly:
83
+
84
+ ```bash
85
+ mkdir -p ~/.config/opencode/plugins/opencode-tokscale
86
+ cp src/tui.tsx src/tokscale.ts src/format.ts src/types.ts \
87
+ ~/.config/opencode/plugins/opencode-tokscale/
88
+ ```
89
+
90
+ Register the local path:
91
+
92
+ ```json
93
+ {
94
+ "plugin": [["./plugins/opencode-tokscale/tui.tsx", { "enabled": true }]]
95
+ }
96
+ ```
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ git clone https://github.com/stevejkang/opencode-tokscale.git
102
+ cd opencode-tokscale
103
+ bun install
104
+ ```
105
+
106
+ Run tests:
107
+
108
+ ```bash
109
+ bun run test
110
+ ```
111
+
112
+ Edit, restart opencode, see changes live.
113
+
114
+ ## License
115
+
116
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "opencode-tokscale",
3
+ "version": "0.0.1-rc.0",
4
+ "type": "module",
5
+ "description": "OpenCode TUI sidebar plugin displaying tokscale token usage statistics and costs",
6
+ "exports": {
7
+ "./tui": "./src/tui.tsx"
8
+ },
9
+ "files": [
10
+ "src/tui.tsx",
11
+ "src/tokscale.ts",
12
+ "src/types.ts",
13
+ "src/format.ts"
14
+ ],
15
+ "scripts": {
16
+ "test": "vitest run",
17
+ "test:watch": "vitest"
18
+ },
19
+ "keywords": [
20
+ "opencode",
21
+ "opencode-plugin",
22
+ "tui-plugin",
23
+ "tokscale",
24
+ "token-usage"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/stevejkang/opencode-tokscale.git"
29
+ },
30
+ "author": "stevejkang",
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@opencode-ai/plugin": "^1.4.3"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@opentui/core": {
37
+ "optional": true
38
+ },
39
+ "@opentui/solid": {
40
+ "optional": true
41
+ }
42
+ },
43
+ "devDependencies": {
44
+ "@opentui/core": "^0.1.98",
45
+ "@opentui/solid": "^0.1.98",
46
+ "solid-js": "^1.9.12",
47
+ "vitest": "4.1.4"
48
+ }
49
+ }
package/src/format.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { ModelReportJson } from "./types"
2
+
3
+ const currencyFormatter = new Intl.NumberFormat("en-US", {
4
+ style: "currency",
5
+ currency: "USD",
6
+ minimumFractionDigits: 2,
7
+ maximumFractionDigits: 2,
8
+ })
9
+
10
+ export function formatTokens(count: number): string {
11
+ if (count <= 0) return "0"
12
+ if (count >= 1_000_000_000) return `${(count / 1_000_000_000).toFixed(1)}B`
13
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
14
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`
15
+ return String(count)
16
+ }
17
+
18
+ export function formatCost(cost: number): string {
19
+ if (cost <= 0) return "$0.00"
20
+ return currencyFormatter.format(cost)
21
+ }
22
+
23
+ export function computeTotalTokens(report: ModelReportJson): number {
24
+ return report.totalInput + report.totalOutput + report.totalCacheRead + report.totalCacheWrite
25
+ }
@@ -0,0 +1,80 @@
1
+ import { execFile } from "child_process"
2
+ import type { ModelReportJson, TimePeriod, PeriodStats } from "./types"
3
+ import { PERIOD_FLAGS } from "./types"
4
+
5
+ let cachedDetection: boolean | null = null
6
+
7
+ export function resetDetectionCache(): void {
8
+ cachedDetection = null
9
+ }
10
+
11
+ export function detectTokscale(): Promise<boolean> {
12
+ if (cachedDetection !== null) return Promise.resolve(cachedDetection)
13
+
14
+ return new Promise((resolve) => {
15
+ execFile("which", ["tokscale"], { timeout: 5000 }, (error) => {
16
+ cachedDetection = !error
17
+ resolve(cachedDetection)
18
+ })
19
+ })
20
+ }
21
+
22
+ export function fetchPeriodStats(
23
+ period: TimePeriod,
24
+ options?: { openCodeOnly?: boolean },
25
+ ): Promise<PeriodStats> {
26
+ const args = ["models", "--json", PERIOD_FLAGS[period], "--no-spinner"]
27
+ if (options?.openCodeOnly !== false) args.push("--opencode")
28
+
29
+ return new Promise((resolve, reject) => {
30
+ execFile(
31
+ "tokscale",
32
+ args,
33
+ { timeout: 15000, maxBuffer: 1024 * 1024 },
34
+ (error, stdout) => {
35
+ if (error) {
36
+ reject(new Error(`tokscale CLI failed: ${error.message}`))
37
+ return
38
+ }
39
+ try {
40
+ const report = parseModelReport(stdout as string)
41
+ resolve(reportToStats(report))
42
+ } catch (e) {
43
+ reject(e)
44
+ }
45
+ },
46
+ )
47
+ })
48
+ }
49
+
50
+ export function parseModelReport(stdout: string): ModelReportJson {
51
+ if (!stdout) throw new Error("Empty output from tokscale CLI")
52
+
53
+ let parsed: unknown
54
+ try {
55
+ parsed = JSON.parse(stdout)
56
+ } catch {
57
+ throw new Error(`Invalid JSON from tokscale CLI: ${stdout.slice(0, 100)}`)
58
+ }
59
+
60
+ const report = parsed as Record<string, unknown>
61
+ if (
62
+ typeof report.totalInput !== "number" ||
63
+ typeof report.totalOutput !== "number" ||
64
+ typeof report.totalCost !== "number"
65
+ ) {
66
+ throw new Error("Invalid tokscale report: missing required numeric fields")
67
+ }
68
+
69
+ return parsed as ModelReportJson
70
+ }
71
+
72
+ export function reportToStats(report: ModelReportJson): PeriodStats {
73
+ return {
74
+ totalTokens:
75
+ report.totalInput + report.totalOutput + report.totalCacheRead + report.totalCacheWrite,
76
+ totalCost: report.totalCost,
77
+ totalMessages: report.totalMessages,
78
+ fetchedAt: Date.now(),
79
+ }
80
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,112 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginModule, TuiSlotContext } from "@opencode-ai/plugin/tui"
3
+ import { createSignal } from "solid-js"
4
+ import type { TimePeriod, PeriodState, TokscalePluginOptions } from "./types"
5
+ import { TIME_PERIODS, PERIOD_LABELS } from "./types"
6
+ import { formatTokens, formatCost } from "./format"
7
+ import { detectTokscale, fetchPeriodStats } from "./tokscale"
8
+
9
+ const TOKSCALE_BLUE = "#0073FF"
10
+
11
+ const tui: TuiPlugin = async (api, options, _meta) => {
12
+ const refreshInterval = ((options as TokscalePluginOptions)?.refreshInterval ?? 60) * 1000
13
+ const showOpenCodeOnly = (options as TokscalePluginOptions)?.showOpenCodeOnly ?? true
14
+
15
+ const signals: Record<TimePeriod, [() => PeriodState, (s: PeriodState) => void]> = {} as Record<TimePeriod, [() => PeriodState, (s: PeriodState) => void]>
16
+ for (const period of TIME_PERIODS) {
17
+ signals[period] = createSignal<PeriodState>({ status: "idle", stats: null, error: null })
18
+ }
19
+
20
+ const [installed, setInstalled] = createSignal<boolean | null>(null)
21
+
22
+ let refreshing = false
23
+ async function refresh() {
24
+ if (refreshing) return
25
+ refreshing = true
26
+ try {
27
+ const isInstalled = await detectTokscale()
28
+ setInstalled(isInstalled)
29
+ if (!isInstalled) {
30
+ for (const period of TIME_PERIODS) {
31
+ signals[period][1]({ status: "not-installed", stats: null, error: null })
32
+ }
33
+ return
34
+ }
35
+ await Promise.allSettled(
36
+ TIME_PERIODS.map(async (period) => {
37
+ const setState = signals[period][1]
38
+ setState({ status: "loading", stats: signals[period][0]().stats, error: null })
39
+ try {
40
+ const stats = await fetchPeriodStats(period, {
41
+ openCodeOnly: showOpenCodeOnly,
42
+ })
43
+ setState({ status: "success", stats, error: null })
44
+ } catch (e) {
45
+ setState({ status: "error", stats: signals[period][0]().stats, error: String(e) })
46
+ }
47
+ })
48
+ )
49
+ } finally {
50
+ refreshing = false
51
+ }
52
+ }
53
+
54
+ refresh()
55
+
56
+ const timer = setInterval(refresh, refreshInterval)
57
+ api.lifecycle.onDispose(() => clearInterval(timer))
58
+
59
+ api.slots.register({
60
+ order: 50,
61
+ slots: {
62
+ sidebar_content(ctx: TuiSlotContext, _props: unknown) {
63
+ const t = ctx.theme.current
64
+ const dim = t.textMuted ?? "#546E7A"
65
+ const fgColor = t.text ?? "#EEFFFF"
66
+
67
+ return (
68
+ <box flexDirection="column" marginBottom={1}>
69
+ <box height={1}>
70
+ <text fg={TOKSCALE_BLUE}><b>{"Tokscale"}</b></text>
71
+ </box>
72
+
73
+ {installed() === false ? (
74
+ <box height={1}>
75
+ <text fg={dim}>{"Install: npm i -g @tokscale/cli"}</text>
76
+ </box>
77
+ ) : (
78
+ TIME_PERIODS.map((period) => {
79
+ const state = signals[period][0]()
80
+ const label = PERIOD_LABELS[period]
81
+ return (
82
+ <box height={1} flexDirection="row">
83
+ <text fg={fgColor}>{`${label.padEnd(12)}`}</text>
84
+ {state.status === "loading" && !state.stats ? (
85
+ <text fg={dim}>{"..."}</text>
86
+ ) : state.status === "error" && !state.stats ? (
87
+ <text fg={dim}>{"err"}</text>
88
+ ) : state.stats ? (
89
+ <>
90
+ <text fg={TOKSCALE_BLUE}>{`${formatTokens(state.stats.totalTokens).padStart(7)}`}</text>
91
+ <text fg={dim}>{` ${formatCost(state.stats.totalCost).padStart(9)}`}</text>
92
+ </>
93
+ ) : (
94
+ <text fg={dim}>{"—"}</text>
95
+ )}
96
+ </box>
97
+ )
98
+ })
99
+ )}
100
+ </box>
101
+ ) as any
102
+ },
103
+ },
104
+ })
105
+ }
106
+
107
+ const plugin: TuiPluginModule & { id: string } = {
108
+ id: "opencode-tokscale",
109
+ tui,
110
+ }
111
+
112
+ export default plugin
package/src/types.ts ADDED
@@ -0,0 +1,63 @@
1
+ export interface ModelUsageJson {
2
+ client: string
3
+ mergedClients: string | null
4
+ workspaceKey?: string | null
5
+ workspaceLabel?: string
6
+ model: string
7
+ provider: string
8
+ input: number
9
+ output: number
10
+ cacheRead: number
11
+ cacheWrite: number
12
+ reasoning: number
13
+ messageCount: number
14
+ cost: number
15
+ }
16
+
17
+ export interface ModelReportJson {
18
+ groupBy: string
19
+ entries: ModelUsageJson[]
20
+ totalInput: number
21
+ totalOutput: number
22
+ totalCacheRead: number
23
+ totalCacheWrite: number
24
+ totalMessages: number
25
+ totalCost: number
26
+ processingTimeMs: number
27
+ }
28
+
29
+ export type TimePeriod = "today" | "week" | "month"
30
+
31
+ export interface PeriodStats {
32
+ totalTokens: number
33
+ totalCost: number
34
+ totalMessages: number
35
+ fetchedAt: number
36
+ }
37
+
38
+ export type TokscaleStatus = "idle" | "loading" | "success" | "error" | "not-installed"
39
+
40
+ export interface PeriodState {
41
+ status: TokscaleStatus
42
+ stats: PeriodStats | null
43
+ error: string | null
44
+ }
45
+
46
+ export interface TokscalePluginOptions {
47
+ refreshInterval?: number
48
+ showOpenCodeOnly?: boolean
49
+ }
50
+
51
+ export const PERIOD_FLAGS: Record<TimePeriod, string> = {
52
+ today: "--today",
53
+ week: "--week",
54
+ month: "--month",
55
+ } as const
56
+
57
+ export const PERIOD_LABELS: Record<TimePeriod, string> = {
58
+ today: "Today",
59
+ week: "This Week",
60
+ month: "This Month",
61
+ } as const
62
+
63
+ export const TIME_PERIODS: readonly TimePeriod[] = ["today", "week", "month"] as const