@jgabor/opencode-neuralwatt 0.1.1 → 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 +561 -366
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 { createMemo, 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,43 +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
|
|
541
|
+
const theme = props.api.theme.current;
|
|
394
542
|
|
|
395
|
-
const q = createMemo(() => props.quota())
|
|
396
|
-
const err = createMemo(() => props.error())
|
|
543
|
+
const q = createMemo(() => props.quota());
|
|
544
|
+
const err = createMemo(() => props.error());
|
|
397
545
|
|
|
398
546
|
const creditColor = createMemo<ThemeColor>(() =>
|
|
399
547
|
q() && q()!.balance.credits_remaining_usd <= 0 ? "error" : "primary",
|
|
400
|
-
)
|
|
548
|
+
);
|
|
401
549
|
const creditUsedPct = createMemo(() =>
|
|
402
550
|
q() && q()!.balance.total_credits_usd > 0
|
|
403
551
|
? (q()!.balance.credits_used_usd / q()!.balance.total_credits_usd) * 100
|
|
404
552
|
: 0,
|
|
405
|
-
)
|
|
406
|
-
const sub = createMemo(() => q()?.subscription ?? null)
|
|
553
|
+
);
|
|
554
|
+
const sub = createMemo(() => q()?.subscription ?? null);
|
|
407
555
|
const hasKwh = createMemo(() => {
|
|
408
|
-
const s = sub()
|
|
409
|
-
return Boolean(s && (s.kwh_included ?? 0) > 0)
|
|
410
|
-
})
|
|
556
|
+
const s = sub();
|
|
557
|
+
return Boolean(s && (s.kwh_included ?? 0) > 0);
|
|
558
|
+
});
|
|
411
559
|
const kwhUsedPct = createMemo(() => {
|
|
412
|
-
const s = sub()
|
|
560
|
+
const s = sub();
|
|
413
561
|
return hasKwh() && s && s.kwh_included! > 0
|
|
414
562
|
? (s.kwh_used! / s.kwh_included!) * 100
|
|
415
|
-
: 0
|
|
416
|
-
})
|
|
417
|
-
const kwhColor = createMemo<ThemeColor>(() => {
|
|
418
|
-
const s = sub()
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
});
|
|
421
574
|
const accountingColor = createMemo<ThemeColor>(() =>
|
|
422
575
|
q()?.balance.accounting_method === "energy" ? "success" : "info",
|
|
423
|
-
)
|
|
576
|
+
);
|
|
424
577
|
|
|
425
578
|
return (
|
|
426
579
|
<box
|
|
@@ -437,7 +590,10 @@ function SidebarView(props: {
|
|
|
437
590
|
|
|
438
591
|
<box flexDirection="row" justifyContent="space-between">
|
|
439
592
|
<box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1}>
|
|
440
|
-
<text
|
|
593
|
+
<text
|
|
594
|
+
fg={theme.selectedListItemText}
|
|
595
|
+
attributes={TextAttributes.BOLD}
|
|
596
|
+
>
|
|
441
597
|
Neuralwatt
|
|
442
598
|
</text>
|
|
443
599
|
</box>
|
|
@@ -461,7 +617,9 @@ function SidebarView(props: {
|
|
|
461
617
|
<LegendRow
|
|
462
618
|
theme={theme}
|
|
463
619
|
marker="░"
|
|
464
|
-
markerColor={
|
|
620
|
+
markerColor={
|
|
621
|
+
q()!.balance.credits_remaining_usd <= 0 ? "error" : "borderSubtle"
|
|
622
|
+
}
|
|
465
623
|
label="Credits remaining"
|
|
466
624
|
value={formatCurrency(q()!.balance.credits_remaining_usd)}
|
|
467
625
|
/>
|
|
@@ -484,8 +642,16 @@ function SidebarView(props: {
|
|
|
484
642
|
hasKwh={hasKwh()}
|
|
485
643
|
sub={sub()!}
|
|
486
644
|
kwhUsedPct={kwhUsedPct()}
|
|
487
|
-
kwhColor={kwhColor()}
|
|
488
|
-
|
|
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
|
+
}
|
|
489
655
|
monthCost={q()!.usage.current_month.cost_usd}
|
|
490
656
|
monthKwh={q()!.usage.current_month.energy_kwh}
|
|
491
657
|
lifetimeCost={q()!.usage.lifetime.cost_usd}
|
|
@@ -498,26 +664,34 @@ function SidebarView(props: {
|
|
|
498
664
|
|
|
499
665
|
<Divider theme={theme} />
|
|
500
666
|
</box>
|
|
501
|
-
)
|
|
667
|
+
);
|
|
502
668
|
}
|
|
503
669
|
|
|
504
670
|
function Divider(props: { theme: Theme }) {
|
|
505
|
-
return
|
|
671
|
+
return (
|
|
672
|
+
<box
|
|
673
|
+
border={["bottom"]}
|
|
674
|
+
borderColor={props.theme.borderSubtle}
|
|
675
|
+
height={1}
|
|
676
|
+
/>
|
|
677
|
+
);
|
|
506
678
|
}
|
|
507
679
|
|
|
508
680
|
function LegendRow(props: {
|
|
509
|
-
theme: Theme
|
|
510
|
-
marker?: string
|
|
511
|
-
markerColor?: ThemeColor
|
|
512
|
-
label: string
|
|
513
|
-
value: string
|
|
681
|
+
theme: Theme;
|
|
682
|
+
marker?: string;
|
|
683
|
+
markerColor?: ThemeColor;
|
|
684
|
+
label: string;
|
|
685
|
+
value: string;
|
|
514
686
|
}) {
|
|
515
|
-
const hasMarker = Boolean(props.marker)
|
|
687
|
+
const hasMarker = Boolean(props.marker);
|
|
516
688
|
return (
|
|
517
689
|
<box flexDirection="row" gap={0}>
|
|
518
690
|
<box width={2}>
|
|
519
691
|
{hasMarker ? (
|
|
520
|
-
<text fg={props.theme[props.markerColor ?? "primary"]}>
|
|
692
|
+
<text fg={props.theme[props.markerColor ?? "primary"]}>
|
|
693
|
+
{props.marker}
|
|
694
|
+
</text>
|
|
521
695
|
) : null}
|
|
522
696
|
</box>
|
|
523
697
|
<box flexGrow={1}>
|
|
@@ -525,44 +699,63 @@ function LegendRow(props: {
|
|
|
525
699
|
</box>
|
|
526
700
|
<text fg={props.theme.textMuted}>{props.value}</text>
|
|
527
701
|
</box>
|
|
528
|
-
)
|
|
702
|
+
);
|
|
529
703
|
}
|
|
530
704
|
|
|
531
|
-
function CombinedBar(props: {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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));
|
|
535
711
|
|
|
536
712
|
return (
|
|
537
|
-
<box
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
/>
|
|
540
725
|
) : null}
|
|
541
|
-
<box flexGrow={empty} flexShrink={1} height={1} backgroundColor={props.theme.borderSubtle} />
|
|
542
726
|
</box>
|
|
543
|
-
)
|
|
727
|
+
);
|
|
544
728
|
}
|
|
545
729
|
|
|
546
730
|
function SidebarUsageBlock(props: {
|
|
547
|
-
theme: Theme
|
|
548
|
-
creditUsedPct: number
|
|
549
|
-
creditColor: ThemeColor
|
|
550
|
-
hasKwh: boolean
|
|
551
|
-
sub: Subscription
|
|
552
|
-
kwhUsedPct: number
|
|
553
|
-
kwhColor: ThemeColor
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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;
|
|
559
744
|
}) {
|
|
560
745
|
const metrics = (
|
|
561
746
|
<box width="100%" flexDirection="column" gap={0}>
|
|
562
|
-
<MetricRow
|
|
563
|
-
|
|
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
|
+
/>
|
|
564
757
|
</box>
|
|
565
|
-
)
|
|
758
|
+
);
|
|
566
759
|
|
|
567
760
|
if (props.hasKwh) {
|
|
568
761
|
return (
|
|
@@ -578,14 +771,14 @@ function SidebarUsageBlock(props: {
|
|
|
578
771
|
marker="█"
|
|
579
772
|
markerColor={props.kwhColor}
|
|
580
773
|
label="kWh used"
|
|
581
|
-
value={`${formatNumber(props.sub.kwh_used
|
|
774
|
+
value={`${formatNumber(props.sub.kwh_used, 2)} kWh`}
|
|
582
775
|
/>
|
|
583
776
|
<LegendRow
|
|
584
777
|
theme={props.theme}
|
|
585
778
|
marker="░"
|
|
586
779
|
markerColor="borderSubtle"
|
|
587
780
|
label="kWh remaining"
|
|
588
|
-
value={`${formatNumber(props.sub.kwh_remaining
|
|
781
|
+
value={`${formatNumber(props.sub.kwh_remaining, 2)} kWh`}
|
|
589
782
|
/>
|
|
590
783
|
<LegendRow
|
|
591
784
|
theme={props.theme}
|
|
@@ -600,9 +793,12 @@ function SidebarUsageBlock(props: {
|
|
|
600
793
|
percent={props.kwhUsedPct}
|
|
601
794
|
color={props.kwhColor}
|
|
602
795
|
/>
|
|
796
|
+
{props.inOverage ? (
|
|
797
|
+
<text fg={props.theme.error}>In overage</text>
|
|
798
|
+
) : null}
|
|
603
799
|
{metrics}
|
|
604
800
|
</box>
|
|
605
|
-
)
|
|
801
|
+
);
|
|
606
802
|
}
|
|
607
803
|
|
|
608
804
|
return (
|
|
@@ -614,7 +810,7 @@ function SidebarUsageBlock(props: {
|
|
|
614
810
|
/>
|
|
615
811
|
{metrics}
|
|
616
812
|
</box>
|
|
617
|
-
)
|
|
813
|
+
);
|
|
618
814
|
}
|
|
619
815
|
|
|
620
816
|
function MetricRow(props: { theme: Theme; label: string; value: string }) {
|
|
@@ -625,7 +821,7 @@ function MetricRow(props: { theme: Theme; label: string; value: string }) {
|
|
|
625
821
|
{props.value}
|
|
626
822
|
</text>
|
|
627
823
|
</box>
|
|
628
|
-
)
|
|
824
|
+
);
|
|
629
825
|
}
|
|
630
826
|
|
|
631
827
|
// ---------------------------------------------------------------------------
|
|
@@ -643,107 +839,100 @@ function Card(props: { theme: Theme; title: string; children: JSX.Element }) {
|
|
|
643
839
|
backgroundColor={props.theme.backgroundElement}
|
|
644
840
|
border={["left"]}
|
|
645
841
|
borderColor={props.theme.primary}
|
|
646
|
-
gap={
|
|
842
|
+
gap={0}
|
|
647
843
|
>
|
|
648
844
|
<text fg={props.theme.primary} attributes={TextAttributes.BOLD}>
|
|
649
845
|
{props.title}
|
|
650
846
|
</text>
|
|
651
847
|
{props.children}
|
|
652
848
|
</box>
|
|
653
|
-
)
|
|
849
|
+
);
|
|
654
850
|
}
|
|
655
851
|
|
|
656
|
-
function Metric(props: {
|
|
852
|
+
function Metric(props: {
|
|
853
|
+
theme: Theme;
|
|
854
|
+
label: string;
|
|
855
|
+
value: string;
|
|
856
|
+
valueColor?: ThemeColor;
|
|
857
|
+
}) {
|
|
657
858
|
return (
|
|
658
859
|
<box flexDirection="row" gap={2}>
|
|
659
860
|
<box width={20}>
|
|
660
861
|
<text fg={props.theme.textMuted}>{props.label}</text>
|
|
661
862
|
</box>
|
|
662
|
-
<text
|
|
863
|
+
<text
|
|
864
|
+
fg={props.valueColor ? props.theme[props.valueColor] : props.theme.text}
|
|
865
|
+
attributes={TextAttributes.BOLD}
|
|
866
|
+
>
|
|
663
867
|
{props.value}
|
|
664
868
|
</text>
|
|
665
869
|
</box>
|
|
666
|
-
)
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function Progress(props: {
|
|
670
|
-
theme: Theme
|
|
671
|
-
label: string
|
|
672
|
-
value: number
|
|
673
|
-
total: number
|
|
674
|
-
color: ThemeColor
|
|
675
|
-
}) {
|
|
676
|
-
const pct = props.total > 0 ? Math.max(0, Math.min(100, (props.value / props.total) * 100)) : 0
|
|
677
|
-
const filled = Math.max(0, Math.round(pct))
|
|
678
|
-
const empty = Math.max(0, 100 - filled)
|
|
679
|
-
|
|
680
|
-
return (
|
|
681
|
-
<box width="100%" flexDirection="column" gap={0}>
|
|
682
|
-
<box flexDirection="row" gap={2}>
|
|
683
|
-
<box width={20}>
|
|
684
|
-
<text fg={props.theme.text}>{props.label}</text>
|
|
685
|
-
</box>
|
|
686
|
-
<text fg={props.theme[props.color]} attributes={TextAttributes.BOLD}>
|
|
687
|
-
{`${pct.toFixed(1)}%`}
|
|
688
|
-
</text>
|
|
689
|
-
</box>
|
|
690
|
-
<box width="100%" flexDirection="row" height={1}>
|
|
691
|
-
{filled > 0 ? (
|
|
692
|
-
<box flexGrow={filled} flexShrink={1} height={1} backgroundColor={props.theme[props.color]} />
|
|
693
|
-
) : null}
|
|
694
|
-
<box flexGrow={empty} flexShrink={1} height={1} backgroundColor={props.theme.borderSubtle} />
|
|
695
|
-
</box>
|
|
696
|
-
</box>
|
|
697
|
-
)
|
|
870
|
+
);
|
|
698
871
|
}
|
|
699
872
|
|
|
700
873
|
function FooterButton(props: {
|
|
701
|
-
theme: Theme
|
|
702
|
-
label: string
|
|
703
|
-
primary?: boolean
|
|
704
|
-
onClick: () => void | Promise<void
|
|
874
|
+
theme: Theme;
|
|
875
|
+
label: string;
|
|
876
|
+
primary?: boolean;
|
|
877
|
+
onClick: () => void | Promise<void>;
|
|
705
878
|
}) {
|
|
706
|
-
const primary = props.primary ?? false
|
|
879
|
+
const primary = props.primary ?? false;
|
|
707
880
|
return (
|
|
708
881
|
<box
|
|
709
882
|
paddingLeft={2}
|
|
710
883
|
paddingRight={2}
|
|
711
|
-
backgroundColor={
|
|
884
|
+
backgroundColor={
|
|
885
|
+
primary ? props.theme.primary : props.theme.backgroundElement
|
|
886
|
+
}
|
|
712
887
|
onMouseUp={props.onClick}
|
|
713
888
|
>
|
|
714
|
-
<text fg={primary ? props.theme.selectedListItemText : props.theme.text}>
|
|
889
|
+
<text fg={primary ? props.theme.selectedListItemText : props.theme.text}>
|
|
890
|
+
{props.label}
|
|
891
|
+
</text>
|
|
715
892
|
</box>
|
|
716
|
-
)
|
|
893
|
+
);
|
|
717
894
|
}
|
|
718
895
|
|
|
719
896
|
// ---------------------------------------------------------------------------
|
|
720
897
|
// Types/helpers
|
|
721
898
|
// ---------------------------------------------------------------------------
|
|
722
899
|
|
|
723
|
-
type TuiApi = Parameters<NonNullable<TuiPluginModule["tui"]>>[0]
|
|
724
|
-
type Theme = TuiApi["theme"]["current"]
|
|
725
|
-
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
|
+
>;
|
|
726
906
|
|
|
727
|
-
function formatCurrency(n: number): string {
|
|
728
|
-
|
|
907
|
+
function formatCurrency(n: number | null | undefined): string {
|
|
908
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
909
|
+
return `$${n.toFixed(2)}`;
|
|
729
910
|
}
|
|
730
911
|
|
|
731
|
-
function formatNumber(n: number, digits = 2): string {
|
|
732
|
-
|
|
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
|
+
});
|
|
733
918
|
}
|
|
734
919
|
|
|
735
|
-
function formatInteger(n: number): string {
|
|
736
|
-
|
|
920
|
+
function formatInteger(n: number | null | undefined): string {
|
|
921
|
+
if (n == null || !Number.isFinite(n)) return "—";
|
|
922
|
+
return n.toLocaleString();
|
|
737
923
|
}
|
|
738
924
|
|
|
739
|
-
function formatDate(
|
|
740
|
-
if (!
|
|
741
|
-
|
|
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())}`;
|
|
742
931
|
}
|
|
743
932
|
|
|
744
933
|
function formatStatus(status: string): string {
|
|
745
|
-
if (!status) return "—"
|
|
746
|
-
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());
|
|
747
936
|
}
|
|
748
937
|
|
|
749
938
|
// ---------------------------------------------------------------------------
|
|
@@ -751,39 +940,45 @@ function formatStatus(status: string): string {
|
|
|
751
940
|
// ---------------------------------------------------------------------------
|
|
752
941
|
|
|
753
942
|
function burnRateCurrentMonth(data: QuotaData): number {
|
|
754
|
-
const days = daysElapsedThisMonth(data.snapshot_at)
|
|
755
|
-
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;
|
|
756
945
|
}
|
|
757
946
|
|
|
758
947
|
function burnRateKwh(sub: Subscription, snapshotAt: string): number {
|
|
759
|
-
if (!sub.current_period_start) return 0
|
|
760
|
-
const days = daysElapsedInPeriod(sub.current_period_start, snapshotAt)
|
|
761
|
-
const kwhUsed = sub.kwh_used ?? 0
|
|
762
|
-
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;
|
|
763
952
|
}
|
|
764
953
|
|
|
765
954
|
function daysElapsedThisMonth(snapshotAt: string): number {
|
|
766
|
-
const now = new Date(snapshotAt)
|
|
767
|
-
|
|
955
|
+
const now = new Date(snapshotAt);
|
|
956
|
+
if (Number.isNaN(now.getTime())) return 1;
|
|
957
|
+
return Math.max(1, now.getDate());
|
|
768
958
|
}
|
|
769
959
|
|
|
770
960
|
function daysElapsedInPeriod(start: string, snapshotAt: string): number {
|
|
771
|
-
const
|
|
772
|
-
|
|
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);
|
|
773
966
|
}
|
|
774
967
|
|
|
775
968
|
function estimateDuration(balance: number, burnRate: number): string {
|
|
776
|
-
if (
|
|
777
|
-
|
|
778
|
-
if (
|
|
779
|
-
|
|
780
|
-
if (days <
|
|
781
|
-
|
|
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`;
|
|
782
977
|
}
|
|
783
978
|
|
|
784
979
|
const plugin: TuiPluginModule & { id: string } = {
|
|
785
980
|
id: "jgabor.neuralwatt-quota",
|
|
786
981
|
tui,
|
|
787
|
-
}
|
|
982
|
+
};
|
|
788
983
|
|
|
789
|
-
export default plugin
|
|
984
|
+
export default plugin;
|