@jgabor/opencode-neuralwatt 0.1.0 → 0.2.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/README.md +10 -5
- package/package.json +1 -1
- package/tui.tsx +594 -385
package/README.md
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-neuralwatt
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="./screenshot.png" alt="Screenshot of OpenCode with opencode-neuralwatt sidebar widget visible." height="300" />
|
|
5
|
+
<img src="./screenshot-modal.png" alt="Screenshot of opencode-neuralwatt modal inside OpenCode." height="300" />
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
8
|
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
9
|
|
|
@@ -17,7 +22,7 @@ Or, configure manually by editing `~/.config/opencode/tui.json`:
|
|
|
17
22
|
```jsonc
|
|
18
23
|
{
|
|
19
24
|
"$schema": "https://opencode.ai/tui.json",
|
|
20
|
-
"plugin": ["@jgabor/opencode-neuralwatt"]
|
|
25
|
+
"plugin": ["@jgabor/opencode-neuralwatt"],
|
|
21
26
|
}
|
|
22
27
|
```
|
|
23
28
|
|
|
@@ -48,13 +53,13 @@ The plugin reads `NEURALWATT_API_KEY` at startup and refreshes quota every 15s.
|
|
|
48
53
|
|
|
49
54
|
## How it works
|
|
50
55
|
|
|
51
|
-
The plugin is a TUI-only OpenCode plugin module
|
|
56
|
+
The plugin is a TUI-only OpenCode plugin module, which is automatically loaded and executed with Bun. Quota data is fetched from `https://api.neuralwatt.com/v1/quota` with Bearer auth, retry/backoff, and 429 handling.
|
|
52
57
|
|
|
53
58
|
## Requirements
|
|
54
59
|
|
|
55
60
|
- OpenCode `^1.0.0`
|
|
56
61
|
- A Neuralwatt API key in `NEURALWATT_API_KEY`
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
---
|
|
59
64
|
|
|
60
|
-
MIT
|
|
65
|
+
**License:** [MIT](./LICENSE) · **Author:** [Jonathan Gabor](https://jgabor.se) · **Version:** 0.2.0
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@jgabor/opencode-neuralwatt",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenCode TUI plugin that shows Neuralwatt account quota, usage, and burn rate in a sidebar widget and panel.",
|
|
7
7
|
"exports": {
|
package/tui.tsx
CHANGED
|
@@ -1,149 +1,153 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
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"
|
|
3
|
+
import { TextAttributes } from "@opentui/core";
|
|
4
|
+
import { createMemo, createSignal, type JSX } from "solid-js";
|
|
5
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
6
6
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Types
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
type UsageBucket = {
|
|
12
|
-
cost_usd: number
|
|
13
|
-
requests: number
|
|
14
|
-
tokens: number
|
|
15
|
-
energy_kwh: number
|
|
16
|
-
}
|
|
12
|
+
cost_usd: number;
|
|
13
|
+
requests: number;
|
|
14
|
+
tokens: number;
|
|
15
|
+
energy_kwh: number;
|
|
16
|
+
};
|
|
17
17
|
|
|
18
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
|
-
}
|
|
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
30
|
|
|
31
31
|
type QuotaData = {
|
|
32
|
-
snapshot_at: string
|
|
32
|
+
snapshot_at: string;
|
|
33
33
|
balance: {
|
|
34
|
-
credits_remaining_usd: number
|
|
35
|
-
total_credits_usd: number
|
|
36
|
-
credits_used_usd: number
|
|
37
|
-
accounting_method: "energy" | "token"
|
|
38
|
-
}
|
|
34
|
+
credits_remaining_usd: number;
|
|
35
|
+
total_credits_usd: number;
|
|
36
|
+
credits_used_usd: number;
|
|
37
|
+
accounting_method: "energy" | "token";
|
|
38
|
+
};
|
|
39
39
|
usage: {
|
|
40
|
-
lifetime: UsageBucket
|
|
41
|
-
current_month: UsageBucket
|
|
42
|
-
}
|
|
40
|
+
lifetime: UsageBucket;
|
|
41
|
+
current_month: UsageBucket;
|
|
42
|
+
};
|
|
43
43
|
limits: {
|
|
44
|
-
overage_limit_usd: number | null
|
|
45
|
-
rate_limit_tier: string
|
|
46
|
-
}
|
|
47
|
-
subscription: Subscription | null
|
|
44
|
+
overage_limit_usd: number | null;
|
|
45
|
+
rate_limit_tier: string;
|
|
46
|
+
};
|
|
47
|
+
subscription: Subscription | null;
|
|
48
48
|
key: {
|
|
49
|
-
name: string | null
|
|
49
|
+
name: string | null;
|
|
50
50
|
allowance: {
|
|
51
|
-
limit_usd: number
|
|
52
|
-
period: string
|
|
53
|
-
spent_usd: number
|
|
54
|
-
remaining_usd: number
|
|
55
|
-
blocked: boolean
|
|
56
|
-
} | null
|
|
57
|
-
}
|
|
58
|
-
}
|
|
51
|
+
limit_usd: number;
|
|
52
|
+
period: string;
|
|
53
|
+
spent_usd: number;
|
|
54
|
+
remaining_usd: number;
|
|
55
|
+
blocked: boolean;
|
|
56
|
+
} | null;
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
59
|
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
// Config
|
|
62
62
|
// ---------------------------------------------------------------------------
|
|
63
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
|
|
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
69
|
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
// Plugin
|
|
72
72
|
// ---------------------------------------------------------------------------
|
|
73
73
|
|
|
74
74
|
const tui: TuiPlugin = async (api) => {
|
|
75
|
-
type QuotaSlotAPI = Parameters<NonNullable<TuiPluginModule["tui"]>>[0]
|
|
75
|
+
type QuotaSlotAPI = Parameters<NonNullable<TuiPluginModule["tui"]>>[0];
|
|
76
76
|
|
|
77
|
-
const apiKey = process.env.NEURALWATT_API_KEY
|
|
77
|
+
const apiKey = process.env.NEURALWATT_API_KEY;
|
|
78
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)
|
|
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
82
|
|
|
83
|
-
let lastFetchAt = 0
|
|
83
|
+
let lastFetchAt = 0;
|
|
84
84
|
|
|
85
|
-
const sleep = (ms: number) =>
|
|
85
|
+
const sleep = (ms: number) =>
|
|
86
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
86
87
|
|
|
87
88
|
const fetchQuotaOnce = async (): Promise<Response> => {
|
|
88
|
-
const now = Date.now()
|
|
89
|
+
const now = Date.now();
|
|
89
90
|
if (now - lastFetchAt < RATE_LIMIT_BUFFER_MS) {
|
|
90
|
-
await sleep(RATE_LIMIT_BUFFER_MS - (now - lastFetchAt))
|
|
91
|
+
await sleep(RATE_LIMIT_BUFFER_MS - (now - lastFetchAt));
|
|
91
92
|
}
|
|
92
|
-
lastFetchAt = Date.now()
|
|
93
|
+
lastFetchAt = Date.now();
|
|
93
94
|
return fetch(`${API_BASE}/quota`, {
|
|
94
95
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
95
|
-
})
|
|
96
|
-
}
|
|
96
|
+
});
|
|
97
|
+
};
|
|
97
98
|
|
|
98
99
|
const fetchQuota = async () => {
|
|
99
100
|
if (!apiKey) {
|
|
100
|
-
setError("NEURALWATT_API_KEY is not set")
|
|
101
|
-
return
|
|
101
|
+
setError("NEURALWATT_API_KEY is not set");
|
|
102
|
+
return;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
let lastErr: Error | null = null
|
|
105
|
+
let lastErr: Error | null = null;
|
|
105
106
|
|
|
106
107
|
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
107
108
|
try {
|
|
108
|
-
const response = await fetchQuotaOnce()
|
|
109
|
+
const response = await fetchQuotaOnce();
|
|
109
110
|
|
|
110
111
|
if (response.status === 429 && attempt < MAX_RETRIES) {
|
|
111
|
-
const retryAfter = Number(response.headers.get("retry-after"))
|
|
112
|
-
const delay =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
113
|
+
const delay =
|
|
114
|
+
Number.isFinite(retryAfter) && retryAfter > 0
|
|
115
|
+
? retryAfter * 1000
|
|
116
|
+
: RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
|
|
117
|
+
await sleep(delay);
|
|
118
|
+
continue;
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
if (!response.ok) {
|
|
120
|
-
const body = await response.text().catch(() => "")
|
|
121
|
-
throw new Error(
|
|
122
|
+
const body = await response.text().catch(() => "");
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
|
|
125
|
+
);
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
const data = (await response.json()) as QuotaData
|
|
125
|
-
setQuota(data)
|
|
126
|
-
setUpdatedAt(new Date())
|
|
127
|
-
setError(null)
|
|
128
|
-
return
|
|
128
|
+
const data = (await response.json()) as QuotaData;
|
|
129
|
+
setQuota(data);
|
|
130
|
+
setUpdatedAt(new Date());
|
|
131
|
+
setError(null);
|
|
132
|
+
return;
|
|
129
133
|
} catch (err) {
|
|
130
|
-
lastErr = err instanceof Error ? err : new Error(String(err))
|
|
134
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
131
135
|
if (attempt < MAX_RETRIES) {
|
|
132
|
-
await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt))
|
|
133
|
-
continue
|
|
136
|
+
await sleep(RETRY_BASE_DELAY_MS * Math.pow(2, attempt));
|
|
137
|
+
continue;
|
|
134
138
|
}
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
if (lastErr) {
|
|
139
|
-
setError(lastErr.message)
|
|
143
|
+
setError(lastErr.message);
|
|
140
144
|
}
|
|
141
|
-
}
|
|
145
|
+
};
|
|
142
146
|
|
|
143
147
|
// Initial fetch + periodic refresh.
|
|
144
|
-
await fetchQuota()
|
|
145
|
-
const interval = setInterval(fetchQuota, REFRESH_INTERVAL_MS)
|
|
146
|
-
api.lifecycle?.onDispose?.(() => clearInterval(interval))
|
|
148
|
+
await fetchQuota();
|
|
149
|
+
const interval = setInterval(fetchQuota, REFRESH_INTERVAL_MS);
|
|
150
|
+
api.lifecycle?.onDispose?.(() => clearInterval(interval));
|
|
147
151
|
|
|
148
152
|
// Register palette command and slash command.
|
|
149
153
|
const commands = [
|
|
@@ -154,9 +158,10 @@ const tui: TuiPlugin = async (api) => {
|
|
|
154
158
|
category: "Neuralwatt",
|
|
155
159
|
slashName: "nw",
|
|
156
160
|
slashAliases: ["neuralwatt"],
|
|
157
|
-
run: () =>
|
|
161
|
+
run: () =>
|
|
162
|
+
openPanel(api as QuotaSlotAPI, quota, error, updatedAt, fetchQuota),
|
|
158
163
|
},
|
|
159
|
-
]
|
|
164
|
+
];
|
|
160
165
|
|
|
161
166
|
if (api.command?.register) {
|
|
162
167
|
api.command.register(() =>
|
|
@@ -168,7 +173,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
168
173
|
slash: { name: cmd.slashName, aliases: cmd.slashAliases },
|
|
169
174
|
onSelect: cmd.run,
|
|
170
175
|
})),
|
|
171
|
-
)
|
|
176
|
+
);
|
|
172
177
|
}
|
|
173
178
|
|
|
174
179
|
// Sidebar widget.
|
|
@@ -183,14 +188,22 @@ const tui: TuiPlugin = async (api) => {
|
|
|
183
188
|
quota={quota}
|
|
184
189
|
error={error}
|
|
185
190
|
updatedAt={updatedAt}
|
|
186
|
-
onOpen={() =>
|
|
191
|
+
onOpen={() =>
|
|
192
|
+
openPanel(
|
|
193
|
+
api as QuotaSlotAPI,
|
|
194
|
+
quota,
|
|
195
|
+
error,
|
|
196
|
+
updatedAt,
|
|
197
|
+
fetchQuota,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
187
200
|
/>
|
|
188
|
-
)
|
|
201
|
+
);
|
|
189
202
|
},
|
|
190
203
|
},
|
|
191
|
-
})
|
|
204
|
+
});
|
|
192
205
|
}
|
|
193
|
-
}
|
|
206
|
+
};
|
|
194
207
|
|
|
195
208
|
// ---------------------------------------------------------------------------
|
|
196
209
|
// Modal
|
|
@@ -203,7 +216,7 @@ function openPanel(
|
|
|
203
216
|
updatedAt: () => Date | null,
|
|
204
217
|
refresh: () => Promise<void>,
|
|
205
218
|
) {
|
|
206
|
-
api.ui.dialog.setSize("
|
|
219
|
+
api.ui.dialog.setSize("xlarge");
|
|
207
220
|
api.ui.dialog.replace(() => (
|
|
208
221
|
<QuotaPanel
|
|
209
222
|
api={api}
|
|
@@ -213,25 +226,46 @@ function openPanel(
|
|
|
213
226
|
onRefresh={refresh}
|
|
214
227
|
onClose={() => api.ui.dialog.clear()}
|
|
215
228
|
/>
|
|
216
|
-
))
|
|
229
|
+
));
|
|
217
230
|
}
|
|
218
231
|
|
|
219
232
|
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
|
|
233
|
+
api: TuiApi;
|
|
234
|
+
quota: () => QuotaData | null;
|
|
235
|
+
error: () => string | null;
|
|
236
|
+
updatedAt: () => Date | null;
|
|
237
|
+
onRefresh: () => Promise<void>;
|
|
238
|
+
onClose: () => void;
|
|
226
239
|
}) {
|
|
227
|
-
const theme = props.api.theme.current
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
const
|
|
240
|
+
const theme = props.api.theme.current;
|
|
241
|
+
|
|
242
|
+
const q = createMemo(() => props.quota());
|
|
243
|
+
const err = createMemo(() => props.error());
|
|
244
|
+
const updated = createMemo(() => props.updatedAt());
|
|
245
|
+
|
|
246
|
+
const burnRate = createMemo(() => (q() ? burnRateCurrentMonth(q()!) : 0));
|
|
247
|
+
const sub = createMemo(() => q()?.subscription ?? null);
|
|
248
|
+
const allowance = createMemo(() => q()?.key.allowance ?? null);
|
|
249
|
+
const accountingColor = createMemo<ThemeColor>(() =>
|
|
250
|
+
q()?.balance.accounting_method === "energy" ? "success" : "info",
|
|
251
|
+
);
|
|
231
252
|
|
|
232
253
|
return (
|
|
233
|
-
<box
|
|
234
|
-
|
|
254
|
+
<box
|
|
255
|
+
flexDirection="column"
|
|
256
|
+
width="100%"
|
|
257
|
+
paddingTop={1}
|
|
258
|
+
paddingLeft={3}
|
|
259
|
+
paddingRight={3}
|
|
260
|
+
paddingBottom={1}
|
|
261
|
+
gap={1}
|
|
262
|
+
>
|
|
263
|
+
<box
|
|
264
|
+
flexDirection="row"
|
|
265
|
+
justifyContent="space-between"
|
|
266
|
+
alignItems="flex-end"
|
|
267
|
+
flexShrink={0}
|
|
268
|
+
>
|
|
235
269
|
<box flexDirection="column">
|
|
236
270
|
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
|
237
271
|
Neuralwatt
|
|
@@ -245,138 +279,252 @@ function QuotaPanel(props: {
|
|
|
245
279
|
</text>
|
|
246
280
|
</box>
|
|
247
281
|
|
|
248
|
-
<box
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
<text fg={theme.textMuted}>
|
|
255
|
-
Set NEURALWATT_API_KEY in your environment and restart OpenCode.
|
|
256
|
-
</text>
|
|
257
|
-
) : null}
|
|
258
|
-
</Card>
|
|
259
|
-
) : null}
|
|
282
|
+
<box
|
|
283
|
+
height={1}
|
|
284
|
+
flexShrink={0}
|
|
285
|
+
border={["bottom"]}
|
|
286
|
+
borderColor={theme.borderSubtle}
|
|
287
|
+
/>
|
|
260
288
|
|
|
261
|
-
{
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
<
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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>
|
|
289
|
+
<scrollbox flexShrink={1} maxHeight={20} scrollY>
|
|
290
|
+
<box flexDirection="column" flexShrink={1} gap={1}>
|
|
291
|
+
{err() ? (
|
|
292
|
+
<Card theme={theme} title="Error">
|
|
293
|
+
<text fg={theme.error}>{err()}</text>
|
|
294
|
+
{!process.env.NEURALWATT_API_KEY ? (
|
|
295
|
+
<text fg={theme.textMuted}>
|
|
296
|
+
Set NEURALWATT_API_KEY in your environment and restart
|
|
297
|
+
OpenCode.
|
|
298
|
+
</text>
|
|
311
299
|
) : null}
|
|
312
300
|
</Card>
|
|
313
301
|
) : null}
|
|
314
302
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
303
|
+
{q() ? (
|
|
304
|
+
<>
|
|
305
|
+
<Card theme={theme} title="Balance">
|
|
306
|
+
<Metric
|
|
307
|
+
theme={theme}
|
|
308
|
+
label="Remaining"
|
|
309
|
+
value={formatCurrency(q()!.balance.credits_remaining_usd)}
|
|
310
|
+
/>
|
|
311
|
+
<Metric
|
|
312
|
+
theme={theme}
|
|
313
|
+
label="Used"
|
|
314
|
+
value={formatCurrency(q()!.balance.credits_used_usd)}
|
|
315
|
+
/>
|
|
316
|
+
<Metric
|
|
317
|
+
theme={theme}
|
|
318
|
+
label="Total"
|
|
319
|
+
value={formatCurrency(q()!.balance.total_credits_usd)}
|
|
320
|
+
/>
|
|
321
|
+
<Metric
|
|
322
|
+
theme={theme}
|
|
323
|
+
label="Accounting"
|
|
324
|
+
value={q()!.balance.accounting_method}
|
|
325
|
+
valueColor={accountingColor()}
|
|
326
|
+
/>
|
|
327
|
+
<CombinedBar
|
|
328
|
+
theme={theme}
|
|
329
|
+
percent={
|
|
330
|
+
q()!.balance.total_credits_usd > 0
|
|
331
|
+
? (q()!.balance.credits_used_usd /
|
|
332
|
+
q()!.balance.total_credits_usd) *
|
|
333
|
+
100
|
|
334
|
+
: 0
|
|
335
|
+
}
|
|
336
|
+
color={
|
|
337
|
+
q()!.balance.credits_remaining_usd <= 0
|
|
338
|
+
? "error"
|
|
339
|
+
: "primary"
|
|
340
|
+
}
|
|
341
|
+
/>
|
|
342
|
+
</Card>
|
|
343
|
+
|
|
344
|
+
{sub() ? (
|
|
345
|
+
<Card theme={theme} title="Subscription">
|
|
346
|
+
<Metric
|
|
347
|
+
theme={theme}
|
|
348
|
+
label="Plan"
|
|
349
|
+
value={sub()!.plan}
|
|
350
|
+
/>
|
|
351
|
+
<Metric
|
|
352
|
+
theme={theme}
|
|
353
|
+
label="Status"
|
|
354
|
+
value={formatStatus(sub()!.status)}
|
|
355
|
+
/>
|
|
356
|
+
<Metric
|
|
357
|
+
theme={theme}
|
|
358
|
+
label="Start"
|
|
359
|
+
value={formatDate(sub()!.current_period_start)}
|
|
360
|
+
/>
|
|
361
|
+
<Metric
|
|
362
|
+
theme={theme}
|
|
363
|
+
label="End"
|
|
364
|
+
value={formatDate(sub()!.current_period_end)}
|
|
365
|
+
/>
|
|
366
|
+
<Metric
|
|
367
|
+
theme={theme}
|
|
368
|
+
label="Auto renew"
|
|
369
|
+
value={sub()!.auto_renew ? "yes" : "no"}
|
|
370
|
+
/>
|
|
371
|
+
<Metric
|
|
372
|
+
theme={theme}
|
|
373
|
+
label="kWh used"
|
|
374
|
+
value={`${formatNumber(sub()!.kwh_used, 2)} kWh`}
|
|
375
|
+
/>
|
|
376
|
+
<Metric
|
|
377
|
+
theme={theme}
|
|
378
|
+
label="kWh remaining"
|
|
379
|
+
value={`${formatNumber(sub()!.kwh_remaining, 2)} kWh`}
|
|
380
|
+
/>
|
|
381
|
+
<CombinedBar
|
|
382
|
+
theme={theme}
|
|
383
|
+
percent={
|
|
384
|
+
(sub()!.kwh_included ?? 0) > 0
|
|
385
|
+
? ((sub()!.kwh_used ?? 0) /
|
|
386
|
+
(sub()!.kwh_included ?? 0)) *
|
|
387
|
+
100
|
|
388
|
+
: 0
|
|
389
|
+
}
|
|
390
|
+
color={sub()!.in_overage ? "error" : "primary"}
|
|
391
|
+
/>
|
|
392
|
+
{sub()!.in_overage ? (
|
|
393
|
+
<text fg={theme.error}>In overage</text>
|
|
394
|
+
) : null}
|
|
395
|
+
</Card>
|
|
396
|
+
) : null}
|
|
397
|
+
|
|
398
|
+
<Card theme={theme} title="Usage (current month)">
|
|
399
|
+
<Metric
|
|
400
|
+
theme={theme}
|
|
401
|
+
label="Cost"
|
|
402
|
+
value={formatCurrency(q()!.usage.current_month.cost_usd)}
|
|
403
|
+
/>
|
|
404
|
+
<Metric
|
|
405
|
+
theme={theme}
|
|
406
|
+
label="Requests"
|
|
407
|
+
value={formatInteger(q()!.usage.current_month.requests)}
|
|
408
|
+
/>
|
|
409
|
+
<Metric
|
|
410
|
+
theme={theme}
|
|
411
|
+
label="Tokens"
|
|
412
|
+
value={formatInteger(q()!.usage.current_month.tokens)}
|
|
413
|
+
/>
|
|
414
|
+
<Metric
|
|
415
|
+
theme={theme}
|
|
416
|
+
label="Energy"
|
|
417
|
+
value={`${formatNumber(q()!.usage.current_month.energy_kwh, 3)} kWh`}
|
|
418
|
+
/>
|
|
419
|
+
</Card>
|
|
420
|
+
|
|
421
|
+
<Card theme={theme} title="Usage (lifetime)">
|
|
422
|
+
<Metric
|
|
423
|
+
theme={theme}
|
|
424
|
+
label="Cost"
|
|
425
|
+
value={formatCurrency(q()!.usage.lifetime.cost_usd)}
|
|
426
|
+
/>
|
|
427
|
+
<Metric
|
|
428
|
+
theme={theme}
|
|
429
|
+
label="Requests"
|
|
430
|
+
value={formatInteger(q()!.usage.lifetime.requests)}
|
|
431
|
+
/>
|
|
432
|
+
<Metric
|
|
433
|
+
theme={theme}
|
|
434
|
+
label="Tokens"
|
|
435
|
+
value={formatInteger(q()!.usage.lifetime.tokens)}
|
|
436
|
+
/>
|
|
437
|
+
<Metric
|
|
438
|
+
theme={theme}
|
|
439
|
+
label="Energy"
|
|
440
|
+
value={`${formatNumber(q()!.usage.lifetime.energy_kwh, 3)} kWh`}
|
|
441
|
+
/>
|
|
442
|
+
</Card>
|
|
443
|
+
|
|
444
|
+
<Card theme={theme} title="Burn rate">
|
|
445
|
+
<Metric
|
|
446
|
+
theme={theme}
|
|
447
|
+
label="Current month"
|
|
448
|
+
value={`${formatCurrency(burnRate())}/day`}
|
|
449
|
+
/>
|
|
450
|
+
<Metric
|
|
451
|
+
theme={theme}
|
|
452
|
+
label="Credits left"
|
|
453
|
+
value={estimateDuration(
|
|
454
|
+
q()!.balance.credits_remaining_usd,
|
|
455
|
+
burnRate(),
|
|
456
|
+
)}
|
|
457
|
+
/>
|
|
458
|
+
</Card>
|
|
459
|
+
|
|
460
|
+
{allowance() ? (
|
|
461
|
+
<Card theme={theme} title="Key Allowance">
|
|
462
|
+
<Metric
|
|
463
|
+
theme={theme}
|
|
464
|
+
label="Key"
|
|
465
|
+
value={q()!.key.name ?? "—"}
|
|
466
|
+
/>
|
|
467
|
+
<Metric
|
|
468
|
+
theme={theme}
|
|
469
|
+
label="Limit"
|
|
470
|
+
value={formatCurrency(allowance()!.limit_usd)}
|
|
471
|
+
/>
|
|
472
|
+
<Metric
|
|
473
|
+
theme={theme}
|
|
474
|
+
label="Period"
|
|
475
|
+
value={allowance()!.period}
|
|
476
|
+
/>
|
|
477
|
+
<Metric
|
|
478
|
+
theme={theme}
|
|
479
|
+
label="Spent"
|
|
480
|
+
value={formatCurrency(allowance()!.spent_usd)}
|
|
481
|
+
/>
|
|
482
|
+
<Metric
|
|
483
|
+
theme={theme}
|
|
484
|
+
label="Remaining"
|
|
485
|
+
value={formatCurrency(allowance()!.remaining_usd)}
|
|
486
|
+
/>
|
|
487
|
+
<Metric
|
|
488
|
+
theme={theme}
|
|
489
|
+
label="Blocked"
|
|
490
|
+
value={allowance()!.blocked ? "yes" : "no"}
|
|
491
|
+
/>
|
|
492
|
+
</Card>
|
|
493
|
+
) : null}
|
|
494
|
+
</>
|
|
495
|
+
) : !err() ? (
|
|
496
|
+
<Card theme={theme} title="Loading">
|
|
497
|
+
<text fg={theme.textMuted}>Fetching quota from Neuralwatt…</text>
|
|
362
498
|
</Card>
|
|
363
499
|
) : null}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
500
|
+
</box>
|
|
501
|
+
</scrollbox>
|
|
502
|
+
|
|
503
|
+
<box
|
|
504
|
+
flexDirection="row"
|
|
505
|
+
justifyContent="space-between"
|
|
506
|
+
paddingTop={1}
|
|
507
|
+
flexShrink={0}
|
|
508
|
+
>
|
|
509
|
+
<text fg={theme.textMuted}>
|
|
510
|
+
{updated() ? `Updated ${formatDate(updated()!)}` : ""}
|
|
511
|
+
</text>
|
|
373
512
|
<box flexDirection="row" gap={1}>
|
|
374
|
-
<FooterButton
|
|
375
|
-
|
|
513
|
+
<FooterButton
|
|
514
|
+
theme={theme}
|
|
515
|
+
label="refresh"
|
|
516
|
+
onClick={props.onRefresh}
|
|
517
|
+
/>
|
|
518
|
+
<FooterButton
|
|
519
|
+
theme={theme}
|
|
520
|
+
label="close"
|
|
521
|
+
primary
|
|
522
|
+
onClick={props.onClose}
|
|
523
|
+
/>
|
|
376
524
|
</box>
|
|
377
525
|
</box>
|
|
378
526
|
</box>
|
|
379
|
-
)
|
|
527
|
+
);
|
|
380
528
|
}
|
|
381
529
|
|
|
382
530
|
// ---------------------------------------------------------------------------
|
|
@@ -384,29 +532,48 @@ function QuotaPanel(props: {
|
|
|
384
532
|
// ---------------------------------------------------------------------------
|
|
385
533
|
|
|
386
534
|
function SidebarView(props: {
|
|
387
|
-
api: TuiApi
|
|
388
|
-
quota: () => QuotaData | null
|
|
389
|
-
error: () => string | null
|
|
390
|
-
updatedAt: () => Date | null
|
|
391
|
-
onOpen: () => void
|
|
535
|
+
api: TuiApi;
|
|
536
|
+
quota: () => QuotaData | null;
|
|
537
|
+
error: () => string | null;
|
|
538
|
+
updatedAt: () => Date | null;
|
|
539
|
+
onOpen: () => void;
|
|
392
540
|
}) {
|
|
393
|
-
const theme = props.api.theme.current
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
541
|
+
const theme = props.api.theme.current;
|
|
542
|
+
|
|
543
|
+
const q = createMemo(() => props.quota());
|
|
544
|
+
const err = createMemo(() => props.error());
|
|
545
|
+
|
|
546
|
+
const creditColor = createMemo<ThemeColor>(() =>
|
|
547
|
+
q() && q()!.balance.credits_remaining_usd <= 0 ? "error" : "primary",
|
|
548
|
+
);
|
|
549
|
+
const creditUsedPct = createMemo(() =>
|
|
550
|
+
q() && q()!.balance.total_credits_usd > 0
|
|
551
|
+
? (q()!.balance.credits_used_usd / q()!.balance.total_credits_usd) * 100
|
|
552
|
+
: 0,
|
|
553
|
+
);
|
|
554
|
+
const sub = createMemo(() => q()?.subscription ?? null);
|
|
555
|
+
const hasKwh = createMemo(() => {
|
|
556
|
+
const s = sub();
|
|
557
|
+
return Boolean(s && (s.kwh_included ?? 0) > 0);
|
|
558
|
+
});
|
|
559
|
+
const kwhUsedPct = createMemo(() => {
|
|
560
|
+
const s = sub();
|
|
561
|
+
return hasKwh() && s && s.kwh_included! > 0
|
|
562
|
+
? (s.kwh_used! / s.kwh_included!) * 100
|
|
563
|
+
: 0;
|
|
564
|
+
});
|
|
565
|
+
const kwhColor = createMemo<ThemeColor | null>(() => {
|
|
566
|
+
const s = sub();
|
|
567
|
+
if (!hasKwh() || !s) return null;
|
|
568
|
+
return s.in_overage ? "error" : "primary";
|
|
569
|
+
});
|
|
570
|
+
const inOverage = createMemo(() => {
|
|
571
|
+
const s = sub();
|
|
572
|
+
return Boolean(s && s.in_overage);
|
|
573
|
+
});
|
|
574
|
+
const accountingColor = createMemo<ThemeColor>(() =>
|
|
575
|
+
q()?.balance.accounting_method === "energy" ? "success" : "info",
|
|
576
|
+
);
|
|
410
577
|
|
|
411
578
|
return (
|
|
412
579
|
<box
|
|
@@ -423,33 +590,38 @@ function SidebarView(props: {
|
|
|
423
590
|
|
|
424
591
|
<box flexDirection="row" justifyContent="space-between">
|
|
425
592
|
<box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1}>
|
|
426
|
-
<text
|
|
593
|
+
<text
|
|
594
|
+
fg={theme.selectedListItemText}
|
|
595
|
+
attributes={TextAttributes.BOLD}
|
|
596
|
+
>
|
|
427
597
|
Neuralwatt
|
|
428
598
|
</text>
|
|
429
599
|
</box>
|
|
430
|
-
<text fg={q ? theme[accountingColor] : theme.textMuted}>
|
|
431
|
-
{q ? q
|
|
600
|
+
<text fg={q() ? theme[accountingColor()] : theme.textMuted}>
|
|
601
|
+
{q() ? q()!.balance.accounting_method : "loading"}
|
|
432
602
|
</text>
|
|
433
603
|
</box>
|
|
434
604
|
|
|
435
|
-
{err ? (
|
|
436
|
-
<text fg={theme.error}>{err}</text>
|
|
437
|
-
) : q ? (
|
|
605
|
+
{err() ? (
|
|
606
|
+
<text fg={theme.error}>{err()}</text>
|
|
607
|
+
) : q() ? (
|
|
438
608
|
<>
|
|
439
609
|
<box flexDirection="column" gap={0}>
|
|
440
610
|
<LegendRow
|
|
441
611
|
theme={theme}
|
|
442
612
|
marker="█"
|
|
443
|
-
markerColor={creditColor}
|
|
613
|
+
markerColor={creditColor()}
|
|
444
614
|
label="Credits used"
|
|
445
|
-
value={formatCurrency(q
|
|
615
|
+
value={formatCurrency(q()!.balance.credits_used_usd)}
|
|
446
616
|
/>
|
|
447
617
|
<LegendRow
|
|
448
618
|
theme={theme}
|
|
449
619
|
marker="░"
|
|
450
|
-
markerColor={
|
|
620
|
+
markerColor={
|
|
621
|
+
q()!.balance.credits_remaining_usd <= 0 ? "error" : "borderSubtle"
|
|
622
|
+
}
|
|
451
623
|
label="Credits remaining"
|
|
452
|
-
value={formatCurrency(q
|
|
624
|
+
value={formatCurrency(q()!.balance.credits_remaining_usd)}
|
|
453
625
|
/>
|
|
454
626
|
<LegendRow
|
|
455
627
|
theme={theme}
|
|
@@ -457,25 +629,33 @@ function SidebarView(props: {
|
|
|
457
629
|
markerColor="warning"
|
|
458
630
|
label="Credits burn rate"
|
|
459
631
|
value={estimateDuration(
|
|
460
|
-
q
|
|
461
|
-
burnRateCurrentMonth(q),
|
|
632
|
+
q()!.balance.credits_remaining_usd,
|
|
633
|
+
burnRateCurrentMonth(q()!),
|
|
462
634
|
)}
|
|
463
635
|
/>
|
|
464
636
|
</box>
|
|
465
637
|
|
|
466
638
|
<SidebarUsageBlock
|
|
467
639
|
theme={theme}
|
|
468
|
-
creditUsedPct={creditUsedPct}
|
|
469
|
-
creditColor={creditColor}
|
|
470
|
-
hasKwh={
|
|
471
|
-
sub={sub!}
|
|
472
|
-
kwhUsedPct={kwhUsedPct}
|
|
473
|
-
kwhColor={kwhColor}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
640
|
+
creditUsedPct={creditUsedPct()}
|
|
641
|
+
creditColor={creditColor()}
|
|
642
|
+
hasKwh={hasKwh()}
|
|
643
|
+
sub={sub()!}
|
|
644
|
+
kwhUsedPct={kwhUsedPct()}
|
|
645
|
+
kwhColor={kwhColor() ?? "primary"}
|
|
646
|
+
inOverage={inOverage()}
|
|
647
|
+
kwhBurnRate={
|
|
648
|
+
sub()
|
|
649
|
+
? estimateDuration(
|
|
650
|
+
sub()!.kwh_remaining ?? 0,
|
|
651
|
+
burnRateKwh(sub()!, q()!.snapshot_at),
|
|
652
|
+
)
|
|
653
|
+
: "—"
|
|
654
|
+
}
|
|
655
|
+
monthCost={q()!.usage.current_month.cost_usd}
|
|
656
|
+
monthKwh={q()!.usage.current_month.energy_kwh}
|
|
657
|
+
lifetimeCost={q()!.usage.lifetime.cost_usd}
|
|
658
|
+
lifetimeKwh={q()!.usage.lifetime.energy_kwh}
|
|
479
659
|
/>
|
|
480
660
|
</>
|
|
481
661
|
) : (
|
|
@@ -484,26 +664,34 @@ function SidebarView(props: {
|
|
|
484
664
|
|
|
485
665
|
<Divider theme={theme} />
|
|
486
666
|
</box>
|
|
487
|
-
)
|
|
667
|
+
);
|
|
488
668
|
}
|
|
489
669
|
|
|
490
670
|
function Divider(props: { theme: Theme }) {
|
|
491
|
-
return
|
|
671
|
+
return (
|
|
672
|
+
<box
|
|
673
|
+
border={["bottom"]}
|
|
674
|
+
borderColor={props.theme.borderSubtle}
|
|
675
|
+
height={1}
|
|
676
|
+
/>
|
|
677
|
+
);
|
|
492
678
|
}
|
|
493
679
|
|
|
494
680
|
function LegendRow(props: {
|
|
495
|
-
theme: Theme
|
|
496
|
-
marker?: string
|
|
497
|
-
markerColor?: ThemeColor
|
|
498
|
-
label: string
|
|
499
|
-
value: string
|
|
681
|
+
theme: Theme;
|
|
682
|
+
marker?: string;
|
|
683
|
+
markerColor?: ThemeColor;
|
|
684
|
+
label: string;
|
|
685
|
+
value: string;
|
|
500
686
|
}) {
|
|
501
|
-
const hasMarker = Boolean(props.marker)
|
|
687
|
+
const hasMarker = Boolean(props.marker);
|
|
502
688
|
return (
|
|
503
689
|
<box flexDirection="row" gap={0}>
|
|
504
690
|
<box width={2}>
|
|
505
691
|
{hasMarker ? (
|
|
506
|
-
<text fg={props.theme[props.markerColor ?? "primary"]}>
|
|
692
|
+
<text fg={props.theme[props.markerColor ?? "primary"]}>
|
|
693
|
+
{props.marker}
|
|
694
|
+
</text>
|
|
507
695
|
) : null}
|
|
508
696
|
</box>
|
|
509
697
|
<box flexGrow={1}>
|
|
@@ -511,44 +699,63 @@ function LegendRow(props: {
|
|
|
511
699
|
</box>
|
|
512
700
|
<text fg={props.theme.textMuted}>{props.value}</text>
|
|
513
701
|
</box>
|
|
514
|
-
)
|
|
702
|
+
);
|
|
515
703
|
}
|
|
516
704
|
|
|
517
|
-
function CombinedBar(props: {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
705
|
+
function CombinedBar(props: {
|
|
706
|
+
theme: Theme;
|
|
707
|
+
percent: number;
|
|
708
|
+
color: ThemeColor;
|
|
709
|
+
}) {
|
|
710
|
+
const pct = Math.max(0, Math.min(100, props.percent));
|
|
521
711
|
|
|
522
712
|
return (
|
|
523
|
-
<box
|
|
524
|
-
|
|
525
|
-
|
|
713
|
+
<box
|
|
714
|
+
width="100%"
|
|
715
|
+
height={1}
|
|
716
|
+
marginTop={1}
|
|
717
|
+
backgroundColor={props.theme.borderSubtle}
|
|
718
|
+
>
|
|
719
|
+
{pct > 0 ? (
|
|
720
|
+
<box
|
|
721
|
+
width={`${pct}%`}
|
|
722
|
+
height={1}
|
|
723
|
+
backgroundColor={props.theme[props.color]}
|
|
724
|
+
/>
|
|
526
725
|
) : null}
|
|
527
|
-
<box flexGrow={empty} flexShrink={1} height={1} backgroundColor={props.theme.borderSubtle} />
|
|
528
726
|
</box>
|
|
529
|
-
)
|
|
727
|
+
);
|
|
530
728
|
}
|
|
531
729
|
|
|
532
730
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
731
|
+
theme: Theme;
|
|
732
|
+
creditUsedPct: number;
|
|
733
|
+
creditColor: ThemeColor;
|
|
734
|
+
hasKwh: boolean;
|
|
735
|
+
sub: Subscription;
|
|
736
|
+
kwhUsedPct: number;
|
|
737
|
+
kwhColor: ThemeColor;
|
|
738
|
+
inOverage: boolean;
|
|
739
|
+
kwhBurnRate: string;
|
|
740
|
+
monthCost: number;
|
|
741
|
+
monthKwh: number;
|
|
742
|
+
lifetimeCost: number;
|
|
743
|
+
lifetimeKwh: number;
|
|
545
744
|
}) {
|
|
546
745
|
const metrics = (
|
|
547
746
|
<box width="100%" flexDirection="column" gap={0}>
|
|
548
|
-
<MetricRow
|
|
549
|
-
|
|
747
|
+
<MetricRow
|
|
748
|
+
theme={props.theme}
|
|
749
|
+
label="Month"
|
|
750
|
+
value={`${formatCurrency(props.monthCost)} / ${formatNumber(props.monthKwh, 0)} kWh`}
|
|
751
|
+
/>
|
|
752
|
+
<MetricRow
|
|
753
|
+
theme={props.theme}
|
|
754
|
+
label="Lifetime"
|
|
755
|
+
value={`${formatCurrency(props.lifetimeCost)} / ${formatNumber(props.lifetimeKwh, 0)} kWh`}
|
|
756
|
+
/>
|
|
550
757
|
</box>
|
|
551
|
-
)
|
|
758
|
+
);
|
|
552
759
|
|
|
553
760
|
if (props.hasKwh) {
|
|
554
761
|
return (
|
|
@@ -564,14 +771,14 @@ function SidebarUsageBlock(props: {
|
|
|
564
771
|
marker="█"
|
|
565
772
|
markerColor={props.kwhColor}
|
|
566
773
|
label="kWh used"
|
|
567
|
-
value={`${formatNumber(props.sub.kwh_used
|
|
774
|
+
value={`${formatNumber(props.sub.kwh_used, 2)} kWh`}
|
|
568
775
|
/>
|
|
569
776
|
<LegendRow
|
|
570
777
|
theme={props.theme}
|
|
571
778
|
marker="░"
|
|
572
779
|
markerColor="borderSubtle"
|
|
573
780
|
label="kWh remaining"
|
|
574
|
-
value={`${formatNumber(props.sub.kwh_remaining
|
|
781
|
+
value={`${formatNumber(props.sub.kwh_remaining, 2)} kWh`}
|
|
575
782
|
/>
|
|
576
783
|
<LegendRow
|
|
577
784
|
theme={props.theme}
|
|
@@ -586,9 +793,12 @@ function SidebarUsageBlock(props: {
|
|
|
586
793
|
percent={props.kwhUsedPct}
|
|
587
794
|
color={props.kwhColor}
|
|
588
795
|
/>
|
|
796
|
+
{props.inOverage ? (
|
|
797
|
+
<text fg={props.theme.error}>In overage</text>
|
|
798
|
+
) : null}
|
|
589
799
|
{metrics}
|
|
590
800
|
</box>
|
|
591
|
-
)
|
|
801
|
+
);
|
|
592
802
|
}
|
|
593
803
|
|
|
594
804
|
return (
|
|
@@ -600,7 +810,7 @@ function SidebarUsageBlock(props: {
|
|
|
600
810
|
/>
|
|
601
811
|
{metrics}
|
|
602
812
|
</box>
|
|
603
|
-
)
|
|
813
|
+
);
|
|
604
814
|
}
|
|
605
815
|
|
|
606
816
|
function MetricRow(props: { theme: Theme; label: string; value: string }) {
|
|
@@ -611,7 +821,7 @@ function MetricRow(props: { theme: Theme; label: string; value: string }) {
|
|
|
611
821
|
{props.value}
|
|
612
822
|
</text>
|
|
613
823
|
</box>
|
|
614
|
-
)
|
|
824
|
+
);
|
|
615
825
|
}
|
|
616
826
|
|
|
617
827
|
// ---------------------------------------------------------------------------
|
|
@@ -629,107 +839,100 @@ function Card(props: { theme: Theme; title: string; children: JSX.Element }) {
|
|
|
629
839
|
backgroundColor={props.theme.backgroundElement}
|
|
630
840
|
border={["left"]}
|
|
631
841
|
borderColor={props.theme.primary}
|
|
632
|
-
gap={
|
|
842
|
+
gap={0}
|
|
633
843
|
>
|
|
634
844
|
<text fg={props.theme.primary} attributes={TextAttributes.BOLD}>
|
|
635
845
|
{props.title}
|
|
636
846
|
</text>
|
|
637
847
|
{props.children}
|
|
638
848
|
</box>
|
|
639
|
-
)
|
|
849
|
+
);
|
|
640
850
|
}
|
|
641
851
|
|
|
642
|
-
function Metric(props: {
|
|
852
|
+
function Metric(props: {
|
|
853
|
+
theme: Theme;
|
|
854
|
+
label: string;
|
|
855
|
+
value: string;
|
|
856
|
+
valueColor?: ThemeColor;
|
|
857
|
+
}) {
|
|
643
858
|
return (
|
|
644
859
|
<box flexDirection="row" gap={2}>
|
|
645
860
|
<box width={20}>
|
|
646
861
|
<text fg={props.theme.textMuted}>{props.label}</text>
|
|
647
862
|
</box>
|
|
648
|
-
<text
|
|
863
|
+
<text
|
|
864
|
+
fg={props.valueColor ? props.theme[props.valueColor] : props.theme.text}
|
|
865
|
+
attributes={TextAttributes.BOLD}
|
|
866
|
+
>
|
|
649
867
|
{props.value}
|
|
650
868
|
</text>
|
|
651
869
|
</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
|
-
)
|
|
870
|
+
);
|
|
684
871
|
}
|
|
685
872
|
|
|
686
873
|
function FooterButton(props: {
|
|
687
|
-
theme: Theme
|
|
688
|
-
label: string
|
|
689
|
-
primary?: boolean
|
|
690
|
-
onClick: () => void | Promise<void
|
|
874
|
+
theme: Theme;
|
|
875
|
+
label: string;
|
|
876
|
+
primary?: boolean;
|
|
877
|
+
onClick: () => void | Promise<void>;
|
|
691
878
|
}) {
|
|
692
|
-
const primary = props.primary ?? false
|
|
879
|
+
const primary = props.primary ?? false;
|
|
693
880
|
return (
|
|
694
881
|
<box
|
|
695
882
|
paddingLeft={2}
|
|
696
883
|
paddingRight={2}
|
|
697
|
-
backgroundColor={
|
|
884
|
+
backgroundColor={
|
|
885
|
+
primary ? props.theme.primary : props.theme.backgroundElement
|
|
886
|
+
}
|
|
698
887
|
onMouseUp={props.onClick}
|
|
699
888
|
>
|
|
700
|
-
<text fg={primary ? props.theme.selectedListItemText : props.theme.text}>
|
|
889
|
+
<text fg={primary ? props.theme.selectedListItemText : props.theme.text}>
|
|
890
|
+
{props.label}
|
|
891
|
+
</text>
|
|
701
892
|
</box>
|
|
702
|
-
)
|
|
893
|
+
);
|
|
703
894
|
}
|
|
704
895
|
|
|
705
896
|
// ---------------------------------------------------------------------------
|
|
706
897
|
// Types/helpers
|
|
707
898
|
// ---------------------------------------------------------------------------
|
|
708
899
|
|
|
709
|
-
type TuiApi = Parameters<NonNullable<TuiPluginModule["tui"]>>[0]
|
|
710
|
-
type Theme = TuiApi["theme"]["current"]
|
|
711
|
-
type ThemeColor = Exclude<
|
|
900
|
+
type TuiApi = Parameters<NonNullable<TuiPluginModule["tui"]>>[0];
|
|
901
|
+
type Theme = TuiApi["theme"]["current"];
|
|
902
|
+
type ThemeColor = Exclude<
|
|
903
|
+
keyof Theme,
|
|
904
|
+
"thinkingOpacity" | "_hasSelectedListItemText"
|
|
905
|
+
>;
|
|
712
906
|
|
|
713
|
-
function formatCurrency(n: number): string {
|
|
714
|
-
|
|
907
|
+
function formatCurrency(n: number | null | undefined): string {
|
|
908
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
909
|
+
return `$${n.toFixed(2)}`;
|
|
715
910
|
}
|
|
716
911
|
|
|
717
|
-
function formatNumber(n: number, digits = 2): string {
|
|
718
|
-
|
|
912
|
+
function formatNumber(n: number | null | undefined, digits = 2): string {
|
|
913
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
914
|
+
return n.toLocaleString(undefined, {
|
|
915
|
+
minimumFractionDigits: digits,
|
|
916
|
+
maximumFractionDigits: digits,
|
|
917
|
+
});
|
|
719
918
|
}
|
|
720
919
|
|
|
721
|
-
function formatInteger(n: number): string {
|
|
722
|
-
|
|
920
|
+
function formatInteger(n: number | null | undefined): string {
|
|
921
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
922
|
+
return n.toLocaleString();
|
|
723
923
|
}
|
|
724
924
|
|
|
725
|
-
function formatDate(
|
|
726
|
-
if (!
|
|
727
|
-
|
|
925
|
+
function formatDate(value: string | Date | null): string {
|
|
926
|
+
if (!value) return "—";
|
|
927
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
928
|
+
if (Number.isNaN(d.getTime())) return "—";
|
|
929
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
930
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
728
931
|
}
|
|
729
932
|
|
|
730
933
|
function formatStatus(status: string): string {
|
|
731
|
-
if (!status) return "—"
|
|
732
|
-
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
|
934
|
+
if (!status) return "—";
|
|
935
|
+
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
733
936
|
}
|
|
734
937
|
|
|
735
938
|
// ---------------------------------------------------------------------------
|
|
@@ -737,39 +940,45 @@ function formatStatus(status: string): string {
|
|
|
737
940
|
// ---------------------------------------------------------------------------
|
|
738
941
|
|
|
739
942
|
function burnRateCurrentMonth(data: QuotaData): number {
|
|
740
|
-
const days = daysElapsedThisMonth(data.snapshot_at)
|
|
741
|
-
return days > 0 ? data.usage.current_month.cost_usd / days : 0
|
|
943
|
+
const days = daysElapsedThisMonth(data.snapshot_at);
|
|
944
|
+
return days > 0 ? data.usage.current_month.cost_usd / days : 0;
|
|
742
945
|
}
|
|
743
946
|
|
|
744
947
|
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
|
|
948
|
+
if (!sub.current_period_start) return 0;
|
|
949
|
+
const days = daysElapsedInPeriod(sub.current_period_start, snapshotAt);
|
|
950
|
+
const kwhUsed = sub.kwh_used ?? 0;
|
|
951
|
+
return days > 0 && kwhUsed > 0 ? kwhUsed / days : 0;
|
|
749
952
|
}
|
|
750
953
|
|
|
751
954
|
function daysElapsedThisMonth(snapshotAt: string): number {
|
|
752
|
-
const now = new Date(snapshotAt)
|
|
753
|
-
|
|
955
|
+
const now = new Date(snapshotAt);
|
|
956
|
+
if (Number.isNaN(now.getTime())) return 1;
|
|
957
|
+
return Math.max(1, now.getDate());
|
|
754
958
|
}
|
|
755
959
|
|
|
756
960
|
function daysElapsedInPeriod(start: string, snapshotAt: string): number {
|
|
757
|
-
const
|
|
758
|
-
|
|
961
|
+
const startMs = new Date(start).getTime();
|
|
962
|
+
const snapMs = new Date(snapshotAt).getTime();
|
|
963
|
+
if (Number.isNaN(startMs) || Number.isNaN(snapMs)) return 1;
|
|
964
|
+
const diffMs = snapMs - startMs;
|
|
965
|
+
return Math.max(1, diffMs / 86_400_000);
|
|
759
966
|
}
|
|
760
967
|
|
|
761
968
|
function estimateDuration(balance: number, burnRate: number): string {
|
|
762
|
-
if (
|
|
763
|
-
|
|
764
|
-
if (
|
|
765
|
-
|
|
766
|
-
if (days <
|
|
767
|
-
|
|
969
|
+
if (!Number.isFinite(balance) || !Number.isFinite(burnRate)) return "—";
|
|
970
|
+
if (burnRate <= 0) return "∞";
|
|
971
|
+
if (balance <= 0) return "0";
|
|
972
|
+
const days = balance / burnRate;
|
|
973
|
+
if (days < 1) return "<1d";
|
|
974
|
+
if (days < 30) return `~${Math.round(days)}d`;
|
|
975
|
+
if (days < 365) return `~${Math.round(days / 30)}mo`;
|
|
976
|
+
return `~${(days / 365).toFixed(1)}y`;
|
|
768
977
|
}
|
|
769
978
|
|
|
770
979
|
const plugin: TuiPluginModule & { id: string } = {
|
|
771
980
|
id: "jgabor.neuralwatt-quota",
|
|
772
981
|
tui,
|
|
773
|
-
}
|
|
982
|
+
};
|
|
774
983
|
|
|
775
|
-
export default plugin
|
|
984
|
+
export default plugin;
|