@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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/package.json +56 -0
- 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
|