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 +116 -0
- package/package.json +49 -0
- package/src/format.ts +25 -0
- package/src/tokscale.ts +80 -0
- package/src/tui.tsx +112 -0
- package/src/types.ts +63 -0
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
|
+
}
|
package/src/tokscale.ts
ADDED
|
@@ -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
|