@krishantt/opencode-go-usage 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +58 -2
- package/index.ts +7 -0
- package/package.json +31 -4
- package/tsconfig.json +7 -0
- package/tui.tsx +128 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Krishant Timilsina
|
|
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
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @krishantt/opencode-go-usage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode Go usage limits — with burn-rate pacing — in the OpenCode TUI status bar.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
GO r4% · w42% ⚠10h/2d · m21% ⚠3d/18d
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Shows rolling / weekly / monthly quota consumption of the managed OpenCode Go
|
|
10
|
+
provider, and warns when your current burn rate will exhaust a window before
|
|
11
|
+
it resets.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Requires OpenCode v2.2+ and the OpenCode Go provider.
|
|
16
|
+
|
|
17
|
+
```json title="~/.config/opencode/cli.json"
|
|
18
|
+
{
|
|
19
|
+
"plugins": ["@krishantt/opencode-go-usage"]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Restart the TUI. The indicator appears after its first fetch (up to a minute).
|
|
24
|
+
|
|
25
|
+
## Reading the bar
|
|
26
|
+
|
|
27
|
+
Per window: `r` rolling (5h), `w` weekly, `m` monthly, as percent consumed.
|
|
28
|
+
|
|
29
|
+
| Glyph | Meaning |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| (none) | fine |
|
|
32
|
+
| `▲` | window at ≥ 85% — nearly spent |
|
|
33
|
+
| `!` | window currently rate-limited; value shows `→<time>` until reset |
|
|
34
|
+
| `⚠eta/reset` | at your current pace this window hits 100% in *eta*, but only *reset* remains — plan accordingly |
|
|
35
|
+
| `GO ⋯` | first fetch in flight |
|
|
36
|
+
| `GO ?` | fetch failed or hung; see debug below |
|
|
37
|
+
|
|
38
|
+
Pacing is linear extrapolation of your own usage (samples kept ~6 h in memory,
|
|
39
|
+
rebuilds after a TUI restart) and only appears when exhaustion actually
|
|
40
|
+
precedes the reset — otherwise the bar stays clean. Warns need ≥ 10 minutes
|
|
41
|
+
of history per window; rolling warns only count your current 5h cycle.
|
|
42
|
+
|
|
43
|
+
## Debug
|
|
44
|
+
|
|
45
|
+
Run `/go-usage-debug` in the prompt (also in the Ctrl+P palette) to toggle
|
|
46
|
+
fetch logging to `~/.cache/opencode/go-usage-debug.log`: raw response
|
|
47
|
+
envelopes, errors with stacks, and toggle events. Off by default and resets
|
|
48
|
+
to off on restart because it logs a couple of lines per poll.
|
|
49
|
+
|
|
50
|
+
## Status bar placement
|
|
51
|
+
|
|
52
|
+
`prompt.footer.status` and `home.footer.status` via TUI plugin slots. These
|
|
53
|
+
and `context.client.experimental.usage.limits` are OpenCode v2 internals and
|
|
54
|
+
may change between releases — if the bar goes missing or shows `GO ?`, check
|
|
55
|
+
this repo's issues and your `~/.local/share/opencode/log/opencode.log`.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
2
3
|
"name": "@krishantt/opencode-go-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "OpenCode TUI plugin: OpenCode Go usage limits plus burn-rate pacing warnings in the status bar",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"opencode",
|
|
9
|
+
"opencode-go",
|
|
10
|
+
"opencode-plugin",
|
|
11
|
+
"opencode-tui",
|
|
12
|
+
"usage",
|
|
13
|
+
"usage-limits",
|
|
14
|
+
"quota",
|
|
15
|
+
"pacing",
|
|
16
|
+
"rate-limits"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Krishant Timilsina",
|
|
20
|
+
"repository": "github:krishantt/opencode-go-usage",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./index.ts",
|
|
23
|
+
"./tui": "./tui.tsx"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@opencode/plugin": "latest"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@opentui/core": ">=0.6.6",
|
|
30
|
+
"@opentui/solid": ">=0.6.6",
|
|
31
|
+
"solid-js": ">=1.9.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Bun reads this file when transpiling tui.tsx. OpenCode's TUI solid-transform skips node_modules paths, so the npm-installed copy relies on Bun's default JSX unless this sets the opentui runtime.",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"jsx": "react-jsx",
|
|
5
|
+
"jsxImportSource": "@opentui/solid"
|
|
6
|
+
}
|
|
7
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import { appendFileSync } from "node:fs"
|
|
3
|
+
|
|
4
|
+
// debug sink, written when the toggle flag is on
|
|
5
|
+
const DEBUG_LOG = `${process.env.HOME}/.cache/opencode/go-usage-debug.log`
|
|
6
|
+
|
|
7
|
+
type Window = { percent?: number, resetsAt?: number, limited?: boolean }
|
|
8
|
+
|
|
9
|
+
// Burn-rate history: one sample per poll with all three windows; 6h cap.
|
|
10
|
+
type Sample = { t: number, p: number[] }
|
|
11
|
+
|
|
12
|
+
// Humanize milliseconds to a compact duration like 4h or 2d.
|
|
13
|
+
function human(ms: number): string {
|
|
14
|
+
if (ms < 0) return "now"
|
|
15
|
+
const h = ms / 3.6e6
|
|
16
|
+
if (h >= 48) return `${Math.round(h / 24)}d`
|
|
17
|
+
if (h >= 1) return `${Math.round(h)}h`
|
|
18
|
+
return `${Math.max(1, Math.round(ms / 6e4))}m`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// "⚠eta/reset" when the window's own burn slope hits 100% before its reset,
|
|
22
|
+
// "" otherwise. Resets appear as drops, so analysis starts after the last one.
|
|
23
|
+
function pace(samples: { t: number, p: number }[], resetsAt: number | undefined, now: number): string {
|
|
24
|
+
if (samples.length < 2) return ""
|
|
25
|
+
let start = 0
|
|
26
|
+
for (let i = 1; i < samples.length; i++)
|
|
27
|
+
if (samples[i]!.p < samples[i - 1]!.p) start = i
|
|
28
|
+
const run = samples.slice(start)
|
|
29
|
+
const first = run[0]!
|
|
30
|
+
const last = run[run.length - 1]!
|
|
31
|
+
const mins = (last.t - first.t) / 6e4
|
|
32
|
+
if (mins < 10) return ""
|
|
33
|
+
const perMin = (last.p - first.p) / mins
|
|
34
|
+
if (perMin <= 0) return ""
|
|
35
|
+
const etaMin = (100 - last.p) / perMin
|
|
36
|
+
const remainMin = ((resetsAt ?? 0) - now) / 6e4
|
|
37
|
+
if (etaMin <= 0 || etaMin >= remainMin) return ""
|
|
38
|
+
return ` ⚠${human(etaMin * 6e4)}/${human(remainMin * 6e4)}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default Plugin.define({
|
|
42
|
+
id: "go-usage",
|
|
43
|
+
setup(context) {
|
|
44
|
+
// windows: go rolling/weekly/monthly; ok=false -> "GO ?"
|
|
45
|
+
const [state, update] = context.storage.memory("go-usage", {
|
|
46
|
+
initial: { windows: [] as Window[], ok: true, samples: [] as Sample[], dbg: false },
|
|
47
|
+
})
|
|
48
|
+
const dbg = (msg: string) => {
|
|
49
|
+
if (!state.dbg) return
|
|
50
|
+
try { appendFileSync(DEBUG_LOG, `${new Date().toISOString()} ${msg}\n`) } catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// feature-detect: client.experimental.usage.limits, older client.usage.limits
|
|
54
|
+
const anyClient = context.client as any
|
|
55
|
+
const call = anyClient?.experimental?.usage?.limits ?? anyClient?.usage?.limits
|
|
56
|
+
|
|
57
|
+
const refresh = async () => {
|
|
58
|
+
if (!call) { update((d) => { d.ok = false }); dbg("client method missing"); return }
|
|
59
|
+
try {
|
|
60
|
+
const res = await call()
|
|
61
|
+
dbg(`raw: ${JSON.stringify(res)?.slice(0, 300) ?? String(res)}`)
|
|
62
|
+
const data = res?.data ?? res
|
|
63
|
+
const go = data?.go
|
|
64
|
+
if (!go) throw new Error("no go data")
|
|
65
|
+
const pick = (w: unknown) => (w ?? {}) as Window
|
|
66
|
+
update((d) => {
|
|
67
|
+
d.ok = true
|
|
68
|
+
d.windows = [pick(go.rolling), pick(go.weekly), pick(go.monthly)]
|
|
69
|
+
// memory store predating samples (hot reload) and fetch errors both land here
|
|
70
|
+
d.samples = (d.samples ?? []).concat({ t: Date.now(), p: d.windows.map((w: Window) => Math.round(w.percent ?? 0)) })
|
|
71
|
+
const cutoff = Date.now() - 6 * 3.6e6
|
|
72
|
+
d.samples = d.samples.filter((s: Sample) => s.t > cutoff).slice(-400)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
dbg(`error: ${e instanceof Error ? e.stack : String(e)}`)
|
|
77
|
+
update((d) => {
|
|
78
|
+
d.ok = false
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
refresh()
|
|
83
|
+
// a fetch that hangs must not blank the status bar forever
|
|
84
|
+
const failTimer = setTimeout(() => {
|
|
85
|
+
if (state.windows.length === 0) update((d) => { d.ok = false })
|
|
86
|
+
}, 10_000)
|
|
87
|
+
const timer = setInterval(refresh, 60_000)
|
|
88
|
+
|
|
89
|
+
const render = () => {
|
|
90
|
+
if (!state.ok)
|
|
91
|
+
return <text fg={context.theme.text.muted}>{"GO ?"}</text>
|
|
92
|
+
if (state.windows.length === 0)
|
|
93
|
+
return <text fg={context.theme.text.muted}>{"GO ⋯"}</text>
|
|
94
|
+
const parts = state.windows.map((w: Window, i: number) => {
|
|
95
|
+
const pct = Math.round(w.percent ?? 0)
|
|
96
|
+
// glyph prefix stands in for color: ! = rate-limited, ▲ = nearly spent
|
|
97
|
+
const mark = w.limited ? "!" : pct >= 85 ? "▲" : ""
|
|
98
|
+
const reset = w.limited ? `→${human((w.resetsAt ?? 0) - Date.now())}` : ""
|
|
99
|
+
const warn = pace(state.samples.map((s: Sample) => ({ t: s.t, p: s.p[i] ?? 0 })), w.resetsAt, Date.now())
|
|
100
|
+
return `${mark}${["r", "w", "m"][i]}${pct}%${reset}${warn}`
|
|
101
|
+
})
|
|
102
|
+
return <text fg={context.theme.text.muted}>{`GO ${parts.join(" · ")}`}</text>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
context.ui.slot({ append: "prompt.footer.status", render })
|
|
106
|
+
context.ui.slot({ append: "home.footer.status", render })
|
|
107
|
+
context.keymap.layer(() => ({
|
|
108
|
+
mode: "global",
|
|
109
|
+
commands: [
|
|
110
|
+
{
|
|
111
|
+
id: "go-usage.debug",
|
|
112
|
+
title: "go-usage: toggle debug logging",
|
|
113
|
+
group: "go-usage",
|
|
114
|
+
palette: true,
|
|
115
|
+
slash: { name: "go-usage-debug" },
|
|
116
|
+
run() {
|
|
117
|
+
update((d) => { d.dbg = !d.dbg })
|
|
118
|
+
const on = state.dbg
|
|
119
|
+
dbg(`debug toggled ${on ? "on" : "off"}`)
|
|
120
|
+
context.ui.toast.show({ title: "go-usage", message: `debug ${on ? "on → " + DEBUG_LOG : "off"}`, duration: 4000 })
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
bindings: ["go-usage.debug"],
|
|
125
|
+
}))
|
|
126
|
+
return () => { clearInterval(timer); clearTimeout(failTimer) }
|
|
127
|
+
},
|
|
128
|
+
})
|