@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 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
- # Temporary Holding Version
1
+ # @krishantt/opencode-go-usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,7 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ // Server part is empty; all behavior lives in ./tui.tsx
4
+ export default Plugin.define({
5
+ id: "go-usage",
6
+ setup() {},
7
+ })
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ })