@jgabor/opencode-neuralwatt 0.1.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 +60 -0
  3. package/package.json +56 -0
  4. package/tui.tsx +775 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Gabor
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,60 @@
1
+ # @jgabor/opencode-neuralwatt
2
+
3
+ An [OpenCode](https://opencode.ai/) TUI plugin that surfaces your [Neuralwatt](https://neuralwatt.com) account quota, usage, and burn rate directly inside the OpenCode terminal UI — as a sidebar widget and an on-demand `/nw` panel.
4
+
5
+ ## Install
6
+
7
+ From the CLI:
8
+
9
+ ```
10
+ opencode plugin @jgabor/opencode-neuralwatt --global
11
+ ```
12
+
13
+ This installs the package and registers it in your global OpenCode TUI config (`~/.config/opencode/tui.json`).
14
+
15
+ Or, configure manually by editing `~/.config/opencode/tui.json`:
16
+
17
+ ```jsonc
18
+ {
19
+ "$schema": "https://opencode.ai/tui.json",
20
+ "plugin": ["@jgabor/opencode-neuralwatt"]
21
+ }
22
+ ```
23
+
24
+ Project-scoped install (omits `--global`) writes to `<repo>/.opencode/tui.json` instead.
25
+
26
+ ## Configure
27
+
28
+ Set your Neuralwatt API key in your environment:
29
+
30
+ ```
31
+ export NEURALWATT_API_KEY="nw_..."
32
+ ```
33
+
34
+ The plugin reads `NEURALWATT_API_KEY` at startup and refreshes quota every 15s.
35
+
36
+ ## Usage
37
+
38
+ - **Sidebar widget** — always-visible credit/kWh/burn-rate summary. Click it to open the detail panel.
39
+ - **`/nw`** (alias `/neuralwatt`) — opens the full quota panel (balance, subscription, usage, burn-rate estimates, key allowance). Use `refresh` / `close` footer buttons.
40
+
41
+ ## Status fields shown
42
+
43
+ - Balance: remaining / used / total credits, accounting method (energy vs token)
44
+ - Subscription: plan, status, period, auto-renew, kWh used/remaining, overage
45
+ - Usage: current month and lifetime (cost, requests, tokens, energy)
46
+ - Burn rate: cost/day and estimated runway for both credits and kWh
47
+ - Key allowance: limit, period, spent, remaining, blocked
48
+
49
+ ## How it works
50
+
51
+ The plugin is a TUI-only OpenCode plugin module (`default export { id, tui }`) resolved via the package `exports["./tui"]` entrypoint. OpenCode loads `tui.tsx` directly with Bun — there is no build step and no compiled artifact shipped. Quota data is fetched from `https://api.neuralwatt.com/v1/quota` with bearer auth, retry/backoff, and 429 handling.
52
+
53
+ ## Requirements
54
+
55
+ - OpenCode `^1.0.0`
56
+ - A Neuralwatt API key in `NEURALWATT_API_KEY`
57
+
58
+ ## License
59
+
60
+ MIT
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@jgabor/opencode-neuralwatt",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "description": "OpenCode TUI plugin that shows Neuralwatt account quota, usage, and burn rate in a sidebar widget and panel.",
7
+ "exports": {
8
+ "./tui": {
9
+ "import": "./tui.tsx"
10
+ }
11
+ },
12
+ "files": [
13
+ "tui.tsx",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "keywords": [
18
+ "opencode",
19
+ "opencode-plugin",
20
+ "opencode-tui",
21
+ "tui",
22
+ "neuralwatt",
23
+ "quota",
24
+ "usage"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/jgabor/opencode-neuralwatt.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/jgabor/opencode-neuralwatt/issues"
32
+ },
33
+ "homepage": "https://github.com/jgabor/opencode-neuralwatt#readme",
34
+ "author": "jgabor",
35
+ "license": "MIT",
36
+ "engines": {
37
+ "opencode": "^1.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@opencode-ai/plugin": ">=1.1.42",
41
+ "@opentui/core": "^0.4.2",
42
+ "@opentui/solid": "^0.4.2",
43
+ "solid-js": "^1.9.12"
44
+ },
45
+ "devDependencies": {
46
+ "@opencode-ai/plugin": "^1.1.42",
47
+ "@opentui/core": "^0.4.2",
48
+ "@opentui/solid": "^0.4.2",
49
+ "@types/node": "^25.5.0",
50
+ "solid-js": "^1.9.12",
51
+ "typescript": "^5.8.2"
52
+ },
53
+ "scripts": {
54
+ "typecheck": "tsc --noEmit"
55
+ }
56
+ }
package/tui.tsx ADDED
@@ -0,0 +1,775 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { TextAttributes } from "@opentui/core"
4
+ import { createSignal, type JSX } from "solid-js"
5
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ type UsageBucket = {
12
+ cost_usd: number
13
+ requests: number
14
+ tokens: number
15
+ energy_kwh: number
16
+ }
17
+
18
+ type Subscription = {
19
+ plan: string
20
+ status: string
21
+ billing_interval: string | null
22
+ current_period_start: string | null
23
+ current_period_end: string | null
24
+ auto_renew: boolean | null
25
+ kwh_included: number | null
26
+ kwh_used: number | null
27
+ kwh_remaining: number | null
28
+ in_overage: boolean | null
29
+ }
30
+
31
+ type QuotaData = {
32
+ snapshot_at: string
33
+ balance: {
34
+ credits_remaining_usd: number
35
+ total_credits_usd: number
36
+ credits_used_usd: number
37
+ accounting_method: "energy" | "token"
38
+ }
39
+ usage: {
40
+ lifetime: UsageBucket
41
+ current_month: UsageBucket
42
+ }
43
+ limits: {
44
+ overage_limit_usd: number | null
45
+ rate_limit_tier: string
46
+ }
47
+ subscription: Subscription | null
48
+ key: {
49
+ name: string | null
50
+ allowance: {
51
+ limit_usd: number
52
+ period: string
53
+ spent_usd: number
54
+ remaining_usd: number
55
+ blocked: boolean
56
+ } | null
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Config
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const API_BASE = "https://api.neuralwatt.com/v1"
65
+ const REFRESH_INTERVAL_MS = 15_000
66
+ const RATE_LIMIT_BUFFER_MS = 1_100
67
+ const MAX_RETRIES = 3
68
+ const RETRY_BASE_DELAY_MS = 1_500
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Plugin
72
+ // ---------------------------------------------------------------------------
73
+
74
+ const tui: TuiPlugin = async (api) => {
75
+ type QuotaSlotAPI = Parameters<NonNullable<TuiPluginModule["tui"]>>[0]
76
+
77
+ const apiKey = process.env.NEURALWATT_API_KEY
78
+
79
+ const [quota, setQuota] = createSignal<QuotaData | null>(null)
80
+ const [error, setError] = createSignal<string | null>(null)
81
+ const [updatedAt, setUpdatedAt] = createSignal<Date | null>(null)
82
+
83
+ let lastFetchAt = 0
84
+
85
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
86
+
87
+ const fetchQuotaOnce = async (): Promise<Response> => {
88
+ const now = Date.now()
89
+ if (now - lastFetchAt < RATE_LIMIT_BUFFER_MS) {
90
+ await sleep(RATE_LIMIT_BUFFER_MS - (now - lastFetchAt))
91
+ }
92
+ lastFetchAt = Date.now()
93
+ return fetch(`${API_BASE}/quota`, {
94
+ headers: { Authorization: `Bearer ${apiKey}` },
95
+ })
96
+ }
97
+
98
+ const fetchQuota = async () => {
99
+ if (!apiKey) {
100
+ setError("NEURALWATT_API_KEY is not set")
101
+ return
102
+ }
103
+
104
+ let lastErr: Error | null = null
105
+
106
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
107
+ try {
108
+ const response = await fetchQuotaOnce()
109
+
110
+ if (response.status === 429 && attempt < MAX_RETRIES) {
111
+ const retryAfter = Number(response.headers.get("retry-after"))
112
+ const delay = Number.isFinite(retryAfter) && retryAfter > 0
113
+ ? retryAfter * 1000
114
+ : RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
115
+ await sleep(delay)
116
+ continue
117
+ }
118
+
119
+ if (!response.ok) {
120
+ const body = await response.text().catch(() => "")
121
+ throw new Error(`${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`)
122
+ }
123
+
124
+ const data = (await response.json()) as QuotaData
125
+ setQuota(data)
126
+ setUpdatedAt(new Date())
127
+ setError(null)
128
+ return
129
+ } catch (err) {
130
+ lastErr = err instanceof Error ? err : new Error(String(err))
131
+ if (attempt < MAX_RETRIES) {
132
+ await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt))
133
+ continue
134
+ }
135
+ }
136
+ }
137
+
138
+ if (lastErr) {
139
+ setError(lastErr.message)
140
+ }
141
+ }
142
+
143
+ // Initial fetch + periodic refresh.
144
+ await fetchQuota()
145
+ const interval = setInterval(fetchQuota, REFRESH_INTERVAL_MS)
146
+ api.lifecycle?.onDispose?.(() => clearInterval(interval))
147
+
148
+ // Register palette command and slash command.
149
+ const commands = [
150
+ {
151
+ title: "Neuralwatt Quota",
152
+ name: "neuralwatt.quota",
153
+ description: "Show Neuralwatt account quota and usage",
154
+ category: "Neuralwatt",
155
+ slashName: "nw",
156
+ slashAliases: ["neuralwatt"],
157
+ run: () => openPanel(api as QuotaSlotAPI, quota, error, updatedAt, fetchQuota),
158
+ },
159
+ ]
160
+
161
+ if (api.command?.register) {
162
+ api.command.register(() =>
163
+ commands.map((cmd) => ({
164
+ title: cmd.title,
165
+ value: cmd.name,
166
+ description: cmd.description,
167
+ category: cmd.category,
168
+ slash: { name: cmd.slashName, aliases: cmd.slashAliases },
169
+ onSelect: cmd.run,
170
+ })),
171
+ )
172
+ }
173
+
174
+ // Sidebar widget.
175
+ if (api.slots?.register) {
176
+ api.slots.register({
177
+ order: 100,
178
+ slots: {
179
+ sidebar_content() {
180
+ return (
181
+ <SidebarView
182
+ api={api as QuotaSlotAPI}
183
+ quota={quota}
184
+ error={error}
185
+ updatedAt={updatedAt}
186
+ onOpen={() => openPanel(api as QuotaSlotAPI, quota, error, updatedAt, fetchQuota)}
187
+ />
188
+ )
189
+ },
190
+ },
191
+ })
192
+ }
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Modal
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function openPanel(
200
+ api: TuiApi,
201
+ quota: () => QuotaData | null,
202
+ error: () => string | null,
203
+ updatedAt: () => Date | null,
204
+ refresh: () => Promise<void>,
205
+ ) {
206
+ api.ui.dialog.setSize("large")
207
+ api.ui.dialog.replace(() => (
208
+ <QuotaPanel
209
+ api={api}
210
+ quota={quota}
211
+ error={error}
212
+ updatedAt={updatedAt}
213
+ onRefresh={refresh}
214
+ onClose={() => api.ui.dialog.clear()}
215
+ />
216
+ ))
217
+ }
218
+
219
+ function QuotaPanel(props: {
220
+ api: TuiApi
221
+ quota: () => QuotaData | null
222
+ error: () => string | null
223
+ updatedAt: () => Date | null
224
+ onRefresh: () => Promise<void>
225
+ onClose: () => void
226
+ }) {
227
+ const theme = props.api.theme.current
228
+ const q = props.quota()
229
+ const err = props.error()
230
+ const updated = props.updatedAt()
231
+
232
+ return (
233
+ <box paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
234
+ <box flexDirection="row" justifyContent="space-between">
235
+ <box flexDirection="column">
236
+ <text fg={theme.primary} attributes={TextAttributes.BOLD}>
237
+ Neuralwatt
238
+ </text>
239
+ <text fg={theme.text} attributes={TextAttributes.BOLD}>
240
+ Quota
241
+ </text>
242
+ </box>
243
+ <text fg={theme.textMuted} onMouseUp={props.onClose}>
244
+ esc
245
+ </text>
246
+ </box>
247
+
248
+ <box height={1} border={["bottom"]} borderColor={theme.borderSubtle} />
249
+
250
+ {err ? (
251
+ <Card theme={theme} title="Error">
252
+ <text fg={theme.error}>{err}</text>
253
+ {!process.env.NEURALWATT_API_KEY ? (
254
+ <text fg={theme.textMuted}>
255
+ Set NEURALWATT_API_KEY in your environment and restart OpenCode.
256
+ </text>
257
+ ) : null}
258
+ </Card>
259
+ ) : null}
260
+
261
+ {q ? (
262
+ <>
263
+ <Card theme={theme} title="Balance">
264
+ <Metric
265
+ theme={theme}
266
+ label="Remaining"
267
+ value={formatCurrency(q.balance.credits_remaining_usd)}
268
+ />
269
+ <Metric theme={theme} label="Used" value={formatCurrency(q.balance.credits_used_usd)} />
270
+ <Metric theme={theme} label="Total" value={formatCurrency(q.balance.total_credits_usd)} />
271
+ <Metric theme={theme} label="Accounting" value={q.balance.accounting_method} />
272
+ <Progress
273
+ theme={theme}
274
+ label="Credits used"
275
+ value={q.balance.credits_used_usd}
276
+ total={q.balance.total_credits_usd}
277
+ color={q.balance.credits_remaining_usd <= 0 ? "error" : "primary"}
278
+ />
279
+ </Card>
280
+
281
+ {q.subscription ? (
282
+ <Card theme={theme} title="Subscription">
283
+ <Metric theme={theme} label="Plan" value={q.subscription.plan} />
284
+ <Metric theme={theme} label="Status" value={formatStatus(q.subscription.status)} />
285
+ <Metric
286
+ theme={theme}
287
+ label="Period"
288
+ value={`${formatDate(q.subscription.current_period_start)} → ${formatDate(
289
+ q.subscription.current_period_end,
290
+ )}`}
291
+ />
292
+ <Metric
293
+ theme={theme}
294
+ label="Auto renew"
295
+ value={q.subscription.auto_renew ? "yes" : "no"}
296
+ />
297
+ <Progress
298
+ theme={theme}
299
+ label="kWh used"
300
+ value={q.subscription.kwh_used ?? 0}
301
+ total={q.subscription.kwh_included ?? 0}
302
+ color={q.subscription.in_overage ? "error" : "primary"}
303
+ />
304
+ <Metric
305
+ theme={theme}
306
+ label="kWh remaining"
307
+ value={`${formatNumber(q.subscription.kwh_remaining ?? 0, 2)} kWh`}
308
+ />
309
+ {q.subscription.in_overage ? (
310
+ <text fg={theme.error}>In overage</text>
311
+ ) : null}
312
+ </Card>
313
+ ) : null}
314
+
315
+ <Card theme={theme} title="Usage (current month)">
316
+ <Metric theme={theme} label="Cost" value={formatCurrency(q.usage.current_month.cost_usd)} />
317
+ <Metric theme={theme} label="Requests" value={formatInteger(q.usage.current_month.requests)} />
318
+ <Metric theme={theme} label="Tokens" value={formatInteger(q.usage.current_month.tokens)} />
319
+ <Metric theme={theme} label="Energy" value={`${formatNumber(q.usage.current_month.energy_kwh, 3)} kWh`} />
320
+ </Card>
321
+
322
+ <Card theme={theme} title="Usage (lifetime)">
323
+ <Metric theme={theme} label="Cost" value={formatCurrency(q.usage.lifetime.cost_usd)} />
324
+ <Metric theme={theme} label="Requests" value={formatInteger(q.usage.lifetime.requests)} />
325
+ <Metric theme={theme} label="Tokens" value={formatInteger(q.usage.lifetime.tokens)} />
326
+ <Metric theme={theme} label="Energy" value={`${formatNumber(q.usage.lifetime.energy_kwh, 3)} kWh`} />
327
+ </Card>
328
+
329
+ <Card theme={theme} title="Burn rate">
330
+ <Metric
331
+ theme={theme}
332
+ label="Current month"
333
+ value={`${formatCurrency(burnRateCurrentMonth(q))}/day`}
334
+ />
335
+ <Metric
336
+ theme={theme}
337
+ label="Credits left"
338
+ value={estimateDuration(q.balance.credits_remaining_usd, burnRateCurrentMonth(q))}
339
+ />
340
+ </Card>
341
+
342
+ {q.key.allowance ? (
343
+ <Card theme={theme} title="Key Allowance">
344
+ <Metric theme={theme} label="Key" value={q.key.name ?? "—"} />
345
+ <Metric theme={theme} label="Limit" value={formatCurrency(q.key.allowance.limit_usd)} />
346
+ <Metric theme={theme} label="Period" value={q.key.allowance.period} />
347
+ <Metric
348
+ theme={theme}
349
+ label="Spent"
350
+ value={formatCurrency(q.key.allowance.spent_usd)}
351
+ />
352
+ <Metric
353
+ theme={theme}
354
+ label="Remaining"
355
+ value={formatCurrency(q.key.allowance.remaining_usd)}
356
+ />
357
+ <Metric
358
+ theme={theme}
359
+ label="Blocked"
360
+ value={q.key.allowance.blocked ? "yes" : "no"}
361
+ />
362
+ </Card>
363
+ ) : null}
364
+ </>
365
+ ) : (
366
+ <Card theme={theme} title="Loading">
367
+ <text fg={theme.textMuted}>Fetching quota from Neuralwatt…</text>
368
+ </Card>
369
+ )}
370
+
371
+ <box flexDirection="row" justifyContent="space-between" paddingTop={1}>
372
+ <text fg={theme.textMuted}>{updated ? `Updated ${updated.toLocaleTimeString()}` : ""}</text>
373
+ <box flexDirection="row" gap={1}>
374
+ <FooterButton theme={theme} label="refresh" onClick={props.onRefresh} />
375
+ <FooterButton theme={theme} label="close" primary onClick={props.onClose} />
376
+ </box>
377
+ </box>
378
+ </box>
379
+ )
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Sidebar widget (DCP-style)
384
+ // ---------------------------------------------------------------------------
385
+
386
+ function SidebarView(props: {
387
+ api: TuiApi
388
+ quota: () => QuotaData | null
389
+ error: () => string | null
390
+ updatedAt: () => Date | null
391
+ onOpen: () => void
392
+ }) {
393
+ const theme = props.api.theme.current
394
+ const q = props.quota()
395
+ const err = props.error()
396
+
397
+ const creditColor: ThemeColor =
398
+ q && q.balance.credits_remaining_usd <= 0 ? "error" : "primary"
399
+ const creditUsedPct =
400
+ q && q.balance.total_credits_usd > 0
401
+ ? (q.balance.credits_used_usd / q.balance.total_credits_usd) * 100
402
+ : 0
403
+ const sub = q?.subscription ?? null
404
+ const hasKwh = sub && (sub.kwh_included ?? 0) > 0
405
+ const kwhUsedPct =
406
+ hasKwh && sub.kwh_included! > 0 ? (sub.kwh_used! / sub.kwh_included!) * 100 : 0
407
+ const kwhColor: ThemeColor = hasKwh && sub.in_overage ? "error" : "primary"
408
+ const accountingColor: ThemeColor =
409
+ q?.balance.accounting_method === "energy" ? "success" : "info"
410
+
411
+ return (
412
+ <box
413
+ width="100%"
414
+ flexDirection="column"
415
+ paddingLeft={1}
416
+ paddingRight={1}
417
+ paddingTop={1}
418
+ paddingBottom={1}
419
+ gap={1}
420
+ onMouseUp={props.onOpen}
421
+ >
422
+ <Divider theme={theme} />
423
+
424
+ <box flexDirection="row" justifyContent="space-between">
425
+ <box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1}>
426
+ <text fg={theme.selectedListItemText} attributes={TextAttributes.BOLD}>
427
+ Neuralwatt
428
+ </text>
429
+ </box>
430
+ <text fg={q ? theme[accountingColor] : theme.textMuted}>
431
+ {q ? q.balance.accounting_method : "loading"}
432
+ </text>
433
+ </box>
434
+
435
+ {err ? (
436
+ <text fg={theme.error}>{err}</text>
437
+ ) : q ? (
438
+ <>
439
+ <box flexDirection="column" gap={0}>
440
+ <LegendRow
441
+ theme={theme}
442
+ marker="█"
443
+ markerColor={creditColor}
444
+ label="Credits used"
445
+ value={formatCurrency(q.balance.credits_used_usd)}
446
+ />
447
+ <LegendRow
448
+ theme={theme}
449
+ marker="░"
450
+ markerColor={creditColor === "error" ? "error" : "borderSubtle"}
451
+ label="Credits remaining"
452
+ value={formatCurrency(q.balance.credits_remaining_usd)}
453
+ />
454
+ <LegendRow
455
+ theme={theme}
456
+ marker="🜂"
457
+ markerColor="warning"
458
+ label="Credits burn rate"
459
+ value={estimateDuration(
460
+ q.balance.credits_remaining_usd,
461
+ burnRateCurrentMonth(q),
462
+ )}
463
+ />
464
+ </box>
465
+
466
+ <SidebarUsageBlock
467
+ theme={theme}
468
+ creditUsedPct={creditUsedPct}
469
+ creditColor={creditColor}
470
+ hasKwh={Boolean(hasKwh)}
471
+ sub={sub!}
472
+ kwhUsedPct={kwhUsedPct}
473
+ kwhColor={kwhColor}
474
+ kwhBurnRate={sub ? estimateDuration(sub.kwh_remaining ?? 0, burnRateKwh(sub, q.snapshot_at)) : "—"}
475
+ monthCost={q.usage.current_month.cost_usd}
476
+ monthKwh={q.usage.current_month.energy_kwh}
477
+ lifetimeCost={q.usage.lifetime.cost_usd}
478
+ lifetimeKwh={q.usage.lifetime.energy_kwh}
479
+ />
480
+ </>
481
+ ) : (
482
+ <text fg={theme.textMuted}>Loading…</text>
483
+ )}
484
+
485
+ <Divider theme={theme} />
486
+ </box>
487
+ )
488
+ }
489
+
490
+ function Divider(props: { theme: Theme }) {
491
+ return <box border={["bottom"]} borderColor={props.theme.borderSubtle} height={1} />
492
+ }
493
+
494
+ function LegendRow(props: {
495
+ theme: Theme
496
+ marker?: string
497
+ markerColor?: ThemeColor
498
+ label: string
499
+ value: string
500
+ }) {
501
+ const hasMarker = Boolean(props.marker)
502
+ return (
503
+ <box flexDirection="row" gap={0}>
504
+ <box width={2}>
505
+ {hasMarker ? (
506
+ <text fg={props.theme[props.markerColor ?? "primary"]}>{props.marker}</text>
507
+ ) : null}
508
+ </box>
509
+ <box flexGrow={1}>
510
+ <text fg={props.theme.text}>{props.label}</text>
511
+ </box>
512
+ <text fg={props.theme.textMuted}>{props.value}</text>
513
+ </box>
514
+ )
515
+ }
516
+
517
+ function CombinedBar(props: { theme: Theme; percent: number; color: ThemeColor }) {
518
+ const pct = Math.max(0, Math.min(100, props.percent))
519
+ const filled = Math.max(0, Math.round(pct))
520
+ const empty = Math.max(0, 100 - filled)
521
+
522
+ return (
523
+ <box width="100%" flexDirection="row" height={1}>
524
+ {filled > 0 ? (
525
+ <box flexGrow={filled} flexShrink={1} height={1} backgroundColor={props.theme[props.color]} />
526
+ ) : null}
527
+ <box flexGrow={empty} flexShrink={1} height={1} backgroundColor={props.theme.borderSubtle} />
528
+ </box>
529
+ )
530
+ }
531
+
532
+ function SidebarUsageBlock(props: {
533
+ theme: Theme
534
+ creditUsedPct: number
535
+ creditColor: ThemeColor
536
+ hasKwh: boolean
537
+ sub: Subscription
538
+ kwhUsedPct: number
539
+ kwhColor: ThemeColor
540
+ kwhBurnRate: string
541
+ monthCost: number
542
+ monthKwh: number
543
+ lifetimeCost: number
544
+ lifetimeKwh: number
545
+ }) {
546
+ const metrics = (
547
+ <box width="100%" flexDirection="column" gap={0}>
548
+ <MetricRow theme={props.theme} label="Month" value={`${formatCurrency(props.monthCost)} / ${formatNumber(props.monthKwh, 0)} kWh`} />
549
+ <MetricRow theme={props.theme} label="Lifetime" value={`${formatCurrency(props.lifetimeCost)} / ${formatNumber(props.lifetimeKwh, 0)} kWh`} />
550
+ </box>
551
+ )
552
+
553
+ if (props.hasKwh) {
554
+ return (
555
+ <box width="100%" flexDirection="column" gap={1}>
556
+ <CombinedBar
557
+ theme={props.theme}
558
+ percent={props.creditUsedPct}
559
+ color={props.creditColor}
560
+ />
561
+ <box width="100%" flexDirection="column" gap={0}>
562
+ <LegendRow
563
+ theme={props.theme}
564
+ marker="█"
565
+ markerColor={props.kwhColor}
566
+ label="kWh used"
567
+ value={`${formatNumber(props.sub.kwh_used ?? 0, 1)} kWh`}
568
+ />
569
+ <LegendRow
570
+ theme={props.theme}
571
+ marker="░"
572
+ markerColor="borderSubtle"
573
+ label="kWh remaining"
574
+ value={`${formatNumber(props.sub.kwh_remaining ?? 0, 1)} kWh`}
575
+ />
576
+ <LegendRow
577
+ theme={props.theme}
578
+ marker="🜂"
579
+ markerColor="warning"
580
+ label="kWh burn rate"
581
+ value={props.kwhBurnRate}
582
+ />
583
+ </box>
584
+ <CombinedBar
585
+ theme={props.theme}
586
+ percent={props.kwhUsedPct}
587
+ color={props.kwhColor}
588
+ />
589
+ {metrics}
590
+ </box>
591
+ )
592
+ }
593
+
594
+ return (
595
+ <box width="100%" flexDirection="column" gap={1}>
596
+ <CombinedBar
597
+ theme={props.theme}
598
+ percent={props.creditUsedPct}
599
+ color={props.creditColor}
600
+ />
601
+ {metrics}
602
+ </box>
603
+ )
604
+ }
605
+
606
+ function MetricRow(props: { theme: Theme; label: string; value: string }) {
607
+ return (
608
+ <box flexDirection="row" justifyContent="space-between">
609
+ <text fg={props.theme.textMuted}>{props.label}</text>
610
+ <text fg={props.theme.text} attributes={TextAttributes.BOLD}>
611
+ {props.value}
612
+ </text>
613
+ </box>
614
+ )
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------
618
+ // UI primitives (DCP-style)
619
+ // ---------------------------------------------------------------------------
620
+
621
+ function Card(props: { theme: Theme; title: string; children: JSX.Element }) {
622
+ return (
623
+ <box
624
+ flexDirection="column"
625
+ paddingLeft={2}
626
+ paddingRight={2}
627
+ paddingTop={1}
628
+ paddingBottom={1}
629
+ backgroundColor={props.theme.backgroundElement}
630
+ border={["left"]}
631
+ borderColor={props.theme.primary}
632
+ gap={1}
633
+ >
634
+ <text fg={props.theme.primary} attributes={TextAttributes.BOLD}>
635
+ {props.title}
636
+ </text>
637
+ {props.children}
638
+ </box>
639
+ )
640
+ }
641
+
642
+ function Metric(props: { theme: Theme; label: string; value: string }) {
643
+ return (
644
+ <box flexDirection="row" gap={2}>
645
+ <box width={20}>
646
+ <text fg={props.theme.textMuted}>{props.label}</text>
647
+ </box>
648
+ <text fg={props.theme.text} attributes={TextAttributes.BOLD}>
649
+ {props.value}
650
+ </text>
651
+ </box>
652
+ )
653
+ }
654
+
655
+ function Progress(props: {
656
+ theme: Theme
657
+ label: string
658
+ value: number
659
+ total: number
660
+ color: ThemeColor
661
+ }) {
662
+ const pct = props.total > 0 ? Math.max(0, Math.min(100, (props.value / props.total) * 100)) : 0
663
+ const filled = Math.max(0, Math.round(pct))
664
+ const empty = Math.max(0, 100 - filled)
665
+
666
+ return (
667
+ <box width="100%" flexDirection="column" gap={0}>
668
+ <box flexDirection="row" gap={2}>
669
+ <box width={20}>
670
+ <text fg={props.theme.text}>{props.label}</text>
671
+ </box>
672
+ <text fg={props.theme[props.color]} attributes={TextAttributes.BOLD}>
673
+ {`${pct.toFixed(1)}%`}
674
+ </text>
675
+ </box>
676
+ <box width="100%" flexDirection="row" height={1}>
677
+ {filled > 0 ? (
678
+ <box flexGrow={filled} flexShrink={1} height={1} backgroundColor={props.theme[props.color]} />
679
+ ) : null}
680
+ <box flexGrow={empty} flexShrink={1} height={1} backgroundColor={props.theme.borderSubtle} />
681
+ </box>
682
+ </box>
683
+ )
684
+ }
685
+
686
+ function FooterButton(props: {
687
+ theme: Theme
688
+ label: string
689
+ primary?: boolean
690
+ onClick: () => void | Promise<void>
691
+ }) {
692
+ const primary = props.primary ?? false
693
+ return (
694
+ <box
695
+ paddingLeft={2}
696
+ paddingRight={2}
697
+ backgroundColor={primary ? props.theme.primary : props.theme.backgroundElement}
698
+ onMouseUp={props.onClick}
699
+ >
700
+ <text fg={primary ? props.theme.selectedListItemText : props.theme.text}>{props.label}</text>
701
+ </box>
702
+ )
703
+ }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // Types/helpers
707
+ // ---------------------------------------------------------------------------
708
+
709
+ type TuiApi = Parameters<NonNullable<TuiPluginModule["tui"]>>[0]
710
+ type Theme = TuiApi["theme"]["current"]
711
+ type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
712
+
713
+ function formatCurrency(n: number): string {
714
+ return `$${n.toFixed(2)}`
715
+ }
716
+
717
+ function formatNumber(n: number, digits = 2): string {
718
+ return n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits })
719
+ }
720
+
721
+ function formatInteger(n: number): string {
722
+ return n.toLocaleString()
723
+ }
724
+
725
+ function formatDate(iso: string | null): string {
726
+ if (!iso) return "—"
727
+ return new Date(iso).toLocaleDateString()
728
+ }
729
+
730
+ function formatStatus(status: string): string {
731
+ if (!status) return "—"
732
+ return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // Burn-rate estimates
737
+ // ---------------------------------------------------------------------------
738
+
739
+ function burnRateCurrentMonth(data: QuotaData): number {
740
+ const days = daysElapsedThisMonth(data.snapshot_at)
741
+ return days > 0 ? data.usage.current_month.cost_usd / days : 0
742
+ }
743
+
744
+ function burnRateKwh(sub: Subscription, snapshotAt: string): number {
745
+ if (!sub.current_period_start) return 0
746
+ const days = daysElapsedInPeriod(sub.current_period_start, snapshotAt)
747
+ const kwhUsed = sub.kwh_used ?? 0
748
+ return days > 0 && kwhUsed > 0 ? kwhUsed / days : 0
749
+ }
750
+
751
+ function daysElapsedThisMonth(snapshotAt: string): number {
752
+ const now = new Date(snapshotAt)
753
+ return Math.max(1, now.getDate())
754
+ }
755
+
756
+ function daysElapsedInPeriod(start: string, snapshotAt: string): number {
757
+ const diffMs = new Date(snapshotAt).getTime() - new Date(start).getTime()
758
+ return Math.max(1, diffMs / 86_400_000)
759
+ }
760
+
761
+ function estimateDuration(balance: number, burnRate: number): string {
762
+ if (burnRate <= 0) return "∞"
763
+ const days = balance / burnRate
764
+ if (days < 1) return "<1d"
765
+ if (days < 30) return `~${Math.round(days)}d`
766
+ if (days < 365) return `~${Math.round(days / 30)}mo`
767
+ return `~${(days / 365).toFixed(1)}y`
768
+ }
769
+
770
+ const plugin: TuiPluginModule & { id: string } = {
771
+ id: "jgabor.neuralwatt-quota",
772
+ tui,
773
+ }
774
+
775
+ export default plugin