@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.
Files changed (3) hide show
  1. package/README.md +10 -5
  2. package/package.json +1 -1
  3. package/tui.tsx +594 -385
package/README.md CHANGED
@@ -1,4 +1,9 @@
1
- # @jgabor/opencode-neuralwatt
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 (`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.
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
- ## License
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.1.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) => new Promise<void>((resolve) => setTimeout(resolve, ms))
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 = Number.isFinite(retryAfter) && retryAfter > 0
113
- ? retryAfter * 1000
114
- : RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
115
- await sleep(delay)
116
- continue
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(`${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`)
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: () => openPanel(api as QuotaSlotAPI, quota, error, updatedAt, fetchQuota),
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={() => openPanel(api as QuotaSlotAPI, quota, error, updatedAt, fetchQuota)}
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("large")
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
- const q = props.quota()
229
- const err = props.error()
230
- const updated = props.updatedAt()
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 paddingLeft={3} paddingRight={3} paddingBottom={1} gap={1}>
234
- <box flexDirection="row" justifyContent="space-between">
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 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}
282
+ <box
283
+ height={1}
284
+ flexShrink={0}
285
+ border={["bottom"]}
286
+ borderColor={theme.borderSubtle}
287
+ />
260
288
 
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>
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
- <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
- />
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
- <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>
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 theme={theme} label="refresh" onClick={props.onRefresh} />
375
- <FooterButton theme={theme} label="close" primary onClick={props.onClose} />
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
- 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"
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 fg={theme.selectedListItemText} attributes={TextAttributes.BOLD}>
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.balance.accounting_method : "loading"}
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.balance.credits_used_usd)}
615
+ value={formatCurrency(q()!.balance.credits_used_usd)}
446
616
  />
447
617
  <LegendRow
448
618
  theme={theme}
449
619
  marker="░"
450
- markerColor={creditColor === "error" ? "error" : "borderSubtle"}
620
+ markerColor={
621
+ q()!.balance.credits_remaining_usd <= 0 ? "error" : "borderSubtle"
622
+ }
451
623
  label="Credits remaining"
452
- value={formatCurrency(q.balance.credits_remaining_usd)}
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.balance.credits_remaining_usd,
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={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}
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 <box border={["bottom"]} borderColor={props.theme.borderSubtle} height={1} />
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"]}>{props.marker}</text>
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: { 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)
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 width="100%" flexDirection="row" height={1}>
524
- {filled > 0 ? (
525
- <box flexGrow={filled} flexShrink={1} height={1} backgroundColor={props.theme[props.color]} />
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
- kwhBurnRate: string
541
- monthCost: number
542
- monthKwh: number
543
- lifetimeCost: number
544
- lifetimeKwh: number
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 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`} />
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 ?? 0, 1)} kWh`}
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 ?? 0, 1)} kWh`}
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={1}
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: { theme: Theme; label: string; value: string }) {
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 fg={props.theme.text} attributes={TextAttributes.BOLD}>
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={primary ? props.theme.primary : props.theme.backgroundElement}
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}>{props.label}</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<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
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
- return `$${n.toFixed(2)}`
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
- return n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits })
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
- return n.toLocaleString()
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(iso: string | null): string {
726
- if (!iso) return "—"
727
- return new Date(iso).toLocaleDateString()
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
- return Math.max(1, now.getDate())
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 diffMs = new Date(snapshotAt).getTime() - new Date(start).getTime()
758
- return Math.max(1, diffMs / 86_400_000)
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 (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`
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;