opencode-copilot-budget 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/package.json +35 -0
  4. package/src/index.tsx +296 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-copilot-budget contributors
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,141 @@
1
+ # opencode-copilot-budget
2
+
3
+ Shows your GitHub Copilot premium request budget in the [OpenCode](https://opencode.ai) TUI sidebar. Only visible when the active provider is `github-copilot`.
4
+
5
+ ![Preview](assets/preview.png)
6
+
7
+ ## Features
8
+
9
+ - Progress bar that turns **red** when you reach 90 % of your budget
10
+ - Request count and percentage used
11
+ - Reset date, updated automatically after every AI response
12
+ - 5-minute cache to avoid unnecessary API calls
13
+ - Works with paid and free Copilot plans
14
+
15
+ ## Requirements
16
+
17
+ - [OpenCode](https://opencode.ai) with `github-copilot` as your active provider
18
+ - A GitHub token via one of:
19
+ - `GITHUB_TOKEN` or `GH_TOKEN` environment variable
20
+ - [GitHub CLI](https://cli.github.com) (`gh auth login`)
21
+
22
+ ## Install
23
+
24
+ ### Option A — OpenCode plugin manager
25
+
26
+ ```bash
27
+ opencode plugin opencode-copilot-budget
28
+ ```
29
+
30
+ This installs the plugin and adds it to your `~/.config/opencode/tui.json` automatically.
31
+
32
+ ### Option B — manual
33
+
34
+ Add to `~/.config/opencode/tui.json` (create the file if it doesn't exist):
35
+
36
+ ```json
37
+ {
38
+ "$schema": "https://opencode.ai/tui.json",
39
+ "plugin": ["opencode-copilot-budget"]
40
+ }
41
+ ```
42
+
43
+ ### Option C — local path (no npm)
44
+
45
+ Point OpenCode directly at the source file by absolute path:
46
+
47
+ ```json
48
+ {
49
+ "$schema": "https://opencode.ai/tui.json",
50
+ "plugin": ["/absolute/path/to/opencode-copilot-budget"]
51
+ }
52
+ ```
53
+
54
+ ## Token setup
55
+
56
+ The plugin discovers your GitHub token in this order:
57
+
58
+ 1. `GITHUB_TOKEN` environment variable
59
+ 2. `GH_TOKEN` environment variable
60
+ 3. Output of `gh auth token` (GitHub CLI)
61
+
62
+ If none of the above are available, add the token to your shell profile:
63
+
64
+ ```bash
65
+ # ~/.zshrc or ~/.bashrc
66
+ export GITHUB_TOKEN="ghp_your_token_here"
67
+ ```
68
+
69
+ To pull it from the GitHub CLI:
70
+
71
+ ```bash
72
+ export GITHUB_TOKEN=$(gh auth token)
73
+ ```
74
+
75
+ ## What it shows
76
+
77
+ | Situation | Display |
78
+ |---|---|
79
+ | Capped plan | progress bar + `117 / 1000 Premium Requests` |
80
+ | ≥ 90 % used | progress bar turns red |
81
+ | Unlimited plan | `62 used (unlimited)` |
82
+ | Overage consumed | `+5 overage` (shown below usage) |
83
+ | Reset date known | `Resets on 1 May` (date in bold) |
84
+ | Token missing / network error | `sync unavailable` |
85
+ | First load | `syncing...` |
86
+
87
+ ## Uninstall
88
+
89
+ Remove `opencode-copilot-budget` from the `plugin` array in `~/.config/opencode/tui.json`.
90
+
91
+ ---
92
+
93
+ ## Contributing
94
+
95
+ All logic lives in a single file — `src/index.tsx` — so it's easy to get started.
96
+
97
+ ### Local development setup
98
+
99
+ 1. Clone the repo:
100
+
101
+ ```bash
102
+ git clone https://github.com/bhaskarmelkani/opencode-copilot-budget
103
+ cd opencode-copilot-budget
104
+ npm install
105
+ ```
106
+
107
+ 2. Point OpenCode at your local clone via an absolute path in `~/.config/opencode/tui.json`:
108
+
109
+ ```json
110
+ {
111
+ "$schema": "https://opencode.ai/tui.json",
112
+ "plugin": ["/absolute/path/to/opencode-copilot-budget"]
113
+ }
114
+ ```
115
+
116
+ 3. Edit `src/index.tsx` and restart OpenCode to see changes.
117
+
118
+ ### Codebase overview
119
+
120
+ ```
121
+ src/index.tsx — entire plugin: token discovery, API fetch, caching, and UI
122
+ ```
123
+
124
+ | Area | Function / component |
125
+ |---|---|
126
+ | Token discovery | `discoverToken()` |
127
+ | API response parsing (paid + free tiers) | `parseResponse()` |
128
+ | Fetch with 5-minute cache | `fetchCopilotUsage()` |
129
+ | Progress bar | `ProgressBar` |
130
+ | Usage display (SolidJS) | `UsageDetail`, `View` |
131
+ | Plugin registration | bottom of file |
132
+
133
+ ### Submitting changes
134
+
135
+ - Open an issue first for non-trivial changes so we can align on direction
136
+ - Keep PRs focused — one concern per PR
137
+ - If you add a new display state, update the "What it shows" table above
138
+
139
+ ## License
140
+
141
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "opencode-copilot-budget",
3
+ "version": "1.0.0",
4
+ "description": "GitHub Copilot premium budget in the OpenCode TUI sidebar",
5
+ "author": "Bhaskar Melkani",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/bhaskarmelkani/opencode-copilot-budget.git"
9
+ },
10
+ "homepage": "https://github.com/bhaskarmelkani/opencode-copilot-budget#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/bhaskarmelkani/opencode-copilot-budget/issues"
13
+ },
14
+ "type": "module",
15
+ "exports": {
16
+ "./tui": "./src/index.tsx"
17
+ },
18
+ "files": [
19
+ "src/",
20
+ "LICENSE",
21
+ "README.md"
22
+ ],
23
+ "peerDependencies": {
24
+ "@opencode-ai/plugin": "*",
25
+ "@opentui/solid": "*",
26
+ "solid-js": "*"
27
+ },
28
+ "keywords": [
29
+ "opencode",
30
+ "opencode-plugin",
31
+ "opencode-tui-plugin",
32
+ "github-copilot"
33
+ ],
34
+ "license": "MIT"
35
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,296 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * opencode-copilot-budget
4
+ *
5
+ * Displays your GitHub Copilot premium request budget in the OpenCode TUI
6
+ * sidebar. Automatically refreshes after each AI response. Only visible when
7
+ * the active provider is `github-copilot`.
8
+ *
9
+ * Display format:
10
+ * Copilot Budget
11
+ * ████████░░░░░░░░ 12% Used ← green; turns red at ≥ 90 %
12
+ * 117 / 1000 Premium Requests
13
+ * Resets on 1 May
14
+ *
15
+ * Token discovery (in priority order):
16
+ * 1. GITHUB_TOKEN environment variable
17
+ * 2. GH_TOKEN environment variable
18
+ * 3. `gh auth token` (GitHub CLI)
19
+ *
20
+ * Install:
21
+ * opencode plugin opencode-copilot-budget
22
+ *
23
+ * Or add manually to ~/.config/opencode/tui.json:
24
+ * { "plugin": ["opencode-copilot-budget"] }
25
+ */
26
+
27
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
28
+ import { createMemo, createResource, Match, onCleanup, onMount, Show, Switch } from "solid-js"
29
+ import { execFile } from "node:child_process"
30
+ import { promisify } from "node:util"
31
+
32
+ const id = "copilot-budget.sidebar"
33
+
34
+ const execFileAsync = promisify(execFile)
35
+
36
+ const COPILOT_USER_ENDPOINT = "https://api.github.com/copilot_internal/user"
37
+ const CACHE_TTL_MS = 5 * 60 * 1000
38
+ const REQUEST_TIMEOUT_MS = 10_000
39
+ const BAR_WIDTH = 16
40
+ const BAR_FILL_COLOR = "#3fb950" // GitHub green
41
+ const BAR_DANGER_COLOR = "#f85149" // red when >= 90%
42
+
43
+ // VS Code impersonation headers — kept as constants for easy version updates
44
+ const EDITOR_VERSION = "vscode/1.96.2"
45
+ const EDITOR_PLUGIN_VERSION = "copilot-chat/0.26.7"
46
+ const USER_AGENT = "GitHubCopilotChat/0.26.7"
47
+ const GITHUB_API_VERSION = "2026-01-01"
48
+
49
+ // ─── Types ───────────────────────────────────────────────────────────────────
50
+
51
+ type CopilotUsageData = {
52
+ used: number
53
+ entitlement: number
54
+ percent: number
55
+ unlimited: boolean
56
+ overageCount: number
57
+ overagePermitted: boolean
58
+ resetDate: string | null
59
+ tier: "paid" | "free"
60
+ }
61
+
62
+ // ─── Cache ───────────────────────────────────────────────────────────────────
63
+
64
+ type CacheEntry = { data: CopilotUsageData | null; timestamp: number }
65
+ let _cache: CacheEntry | null = null
66
+
67
+ function bustCache() {
68
+ _cache = null
69
+ }
70
+
71
+ // ─── Token Discovery ─────────────────────────────────────────────────────────
72
+
73
+ async function discoverToken(): Promise<string | null> {
74
+ // 1. GITHUB_TOKEN — standard env var (CI / devcontainers / manual export)
75
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN
76
+
77
+ // 2. GH_TOKEN — alias used by GitHub CLI and many CI environments
78
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN
79
+
80
+ // 3. GitHub CLI — for users who ran `gh auth login`
81
+ try {
82
+ const { stdout } = await execFileAsync("gh", ["auth", "token"], { timeout: 5_000 })
83
+ const token = stdout.trim()
84
+ if (token) return token
85
+ } catch {
86
+ // gh not installed or not authenticated — silent fallthrough
87
+ }
88
+
89
+ return null
90
+ }
91
+
92
+ // ─── Response Parsing ────────────────────────────────────────────────────────
93
+
94
+ function parseResponse(body: unknown): CopilotUsageData | null {
95
+ if (!body || typeof body !== "object") return null
96
+ const data = body as Record<string, unknown>
97
+
98
+ // Paid tier: quota_snapshots.premium_interactions
99
+ const snapshots = data.quota_snapshots as Record<string, unknown> | undefined
100
+ if (snapshots?.premium_interactions && typeof snapshots.premium_interactions === "object") {
101
+ const pi = snapshots.premium_interactions as Record<string, unknown>
102
+ const entitlement = Number(pi.entitlement ?? 0)
103
+ const remaining = Number(pi.remaining ?? 0)
104
+ const unlimited = Boolean(pi.unlimited)
105
+ const used = unlimited
106
+ ? Number(pi.used ?? Math.max(0, entitlement - remaining))
107
+ : Math.max(0, entitlement - remaining)
108
+ const percent = unlimited || entitlement === 0 ? 0 : Math.round((used / entitlement) * 100)
109
+
110
+ return {
111
+ used: Math.round(used),
112
+ entitlement,
113
+ percent,
114
+ unlimited,
115
+ overageCount: Number(pi.overage_count ?? 0),
116
+ overagePermitted: Boolean(pi.overage_permitted),
117
+ resetDate: (data.quota_reset_date_utc as string | undefined) ?? null,
118
+ tier: "paid",
119
+ }
120
+ }
121
+
122
+ // Free tier: limited_user_quotas or monthly_quotas
123
+ const luq = data.limited_user_quotas as Record<string, unknown> | undefined
124
+ const mq = data.monthly_quotas as Record<string, unknown> | undefined
125
+ if (luq || mq) {
126
+ const piLuq = (luq?.premium_interactions as Record<string, unknown> | undefined) ?? {}
127
+ const piMq = (mq?.premium_interactions as Record<string, unknown> | undefined) ?? {}
128
+ const pi = Object.keys(piLuq).length ? piLuq : piMq
129
+
130
+ // Both sources empty — can't derive meaningful usage data
131
+ if (!Object.keys(pi).length) return null
132
+
133
+ const entitlement = Number(pi.entitlement ?? 0)
134
+ const remaining = Number(pi.remaining ?? 0)
135
+ const unlimited = Boolean(pi.unlimited)
136
+ const used = Math.max(0, entitlement - remaining)
137
+ const percent = unlimited || entitlement === 0 ? 0 : Math.round((used / entitlement) * 100)
138
+ const resetDate =
139
+ (data.limited_user_reset_date as string | undefined) ??
140
+ (data.quota_reset_date_utc as string | undefined) ??
141
+ null
142
+
143
+ return {
144
+ used: Math.round(used),
145
+ entitlement,
146
+ percent,
147
+ unlimited,
148
+ overageCount: 0,
149
+ overagePermitted: false,
150
+ resetDate,
151
+ tier: "free",
152
+ }
153
+ }
154
+
155
+ return null
156
+ }
157
+
158
+ // ─── Fetch (with caching) ────────────────────────────────────────────────────
159
+
160
+ async function fetchCopilotUsage(): Promise<CopilotUsageData | null> {
161
+ if (_cache !== null && Date.now() - _cache.timestamp < CACHE_TTL_MS) {
162
+ return _cache.data
163
+ }
164
+
165
+ const token = await discoverToken()
166
+ if (!token) {
167
+ _cache = { data: null, timestamp: Date.now() }
168
+ return null
169
+ }
170
+
171
+ try {
172
+ const response = await fetch(COPILOT_USER_ENDPOINT, {
173
+ headers: {
174
+ Authorization: `token ${token}`,
175
+ Accept: "application/json",
176
+ // Match the headers sent by VS Code's official Copilot Chat extension.
177
+ "Editor-Version": EDITOR_VERSION,
178
+ "Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
179
+ "User-Agent": USER_AGENT,
180
+ "X-Github-Api-Version": GITHUB_API_VERSION,
181
+ },
182
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
183
+ })
184
+
185
+ if (!response.ok) {
186
+ _cache = { data: null, timestamp: Date.now() }
187
+ return null
188
+ }
189
+
190
+ const body = (await response.json()) as unknown
191
+ const parsed = parseResponse(body)
192
+ _cache = { data: parsed, timestamp: Date.now() }
193
+ return parsed
194
+ } catch {
195
+ _cache = { data: null, timestamp: Date.now() }
196
+ return null
197
+ }
198
+ }
199
+
200
+ // ─── UI ──────────────────────────────────────────────────────────────────────
201
+
202
+ function formatResetDate(dateStr: string): string {
203
+ try {
204
+ return new Date(dateStr).toLocaleDateString(undefined, { day: "numeric", month: "long" })
205
+ } catch {
206
+ return dateStr
207
+ }
208
+ }
209
+
210
+ function ProgressBar(props: { percent: number }) {
211
+ const clampedPercent = Math.min(100, Math.max(0, props.percent))
212
+ const filled = Math.round((clampedPercent / 100) * BAR_WIDTH)
213
+ const empty = BAR_WIDTH - filled
214
+ const color = clampedPercent >= 90 ? BAR_DANGER_COLOR : BAR_FILL_COLOR
215
+ return (
216
+ <text fg={color}>{`${"█".repeat(filled)}${"░".repeat(empty)} ${clampedPercent}% Used`}</text>
217
+ )
218
+ }
219
+
220
+ function UsageDetail(props: { api: TuiPluginApi }) {
221
+ const theme = () => props.api.theme.current
222
+ const [usage, { refetch }] = createResource(fetchCopilotUsage)
223
+
224
+ onMount(() => {
225
+ // Refetch whenever the AI finishes responding — exactly when a Copilot
226
+ // request has been consumed. Bust the cache first so we always hit the
227
+ // network and get a fresh count.
228
+ const off = props.api.event.on("session.idle", () => {
229
+ bustCache()
230
+ refetch()
231
+ })
232
+ onCleanup(off)
233
+ })
234
+
235
+ return (
236
+ <Switch>
237
+ <Match when={usage()}>
238
+ {(data) => (
239
+ <box direction="column">
240
+ <Show
241
+ when={!data().unlimited}
242
+ fallback={<text fg={theme().textMuted}>{`${data().used} used (unlimited)`}</text>}
243
+ >
244
+ <ProgressBar percent={data().percent} />
245
+ <text fg={theme().textMuted}>{`${data().used} / ${data().entitlement} Premium Requests`}</text>
246
+ </Show>
247
+ <Show when={data().overageCount > 0}>
248
+ <text fg={theme().warning}>{`+${data().overageCount} overage`}</text>
249
+ </Show>
250
+ <Show when={data().resetDate}>
251
+ <text fg={theme().textMuted}>{"Resets on "}<b>{formatResetDate(data().resetDate!)}</b></text>
252
+ </Show>
253
+ </box>
254
+ )}
255
+ </Match>
256
+ <Match when={usage.loading}>
257
+ <text fg={theme().textMuted}>syncing...</text>
258
+ </Match>
259
+ <Match when={true}>
260
+ <text fg={theme().textMuted}>sync unavailable</text>
261
+ </Match>
262
+ </Switch>
263
+ )
264
+ }
265
+
266
+ function View(props: { api: TuiPluginApi }) {
267
+ const theme = () => props.api.theme.current
268
+ const isCopilot = createMemo(() =>
269
+ props.api.state.provider.some((p) => p.id === "github-copilot"),
270
+ )
271
+
272
+ return (
273
+ <Show when={isCopilot()}>
274
+ <box direction="column">
275
+ <text fg={theme().text}><b>Copilot Budget</b></text>
276
+ <UsageDetail api={props.api} />
277
+ </box>
278
+ </Show>
279
+ )
280
+ }
281
+
282
+ // ─── Plugin Registration ──────────────────────────────────────────────────────
283
+
284
+ const tui: TuiPlugin = async (api) => {
285
+ api.slots.register({
286
+ order: 50, // top of sidebar — before Context (100), MCP (200), LSP (300), etc.
287
+ slots: {
288
+ sidebar_content() {
289
+ return <View api={api} />
290
+ },
291
+ },
292
+ })
293
+ }
294
+
295
+ const plugin: TuiPluginModule & { id: string } = { id, tui }
296
+ export default plugin