@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.
Files changed (3) hide show
  1. package/README.md +10 -5
  2. package/package.json +1 -1
  3. package/tui.tsx +561 -366
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.1",
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) => 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,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
- return hasKwh() && s && s.in_overage ? "error" : "primary"
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 fg={theme.selectedListItemText} attributes={TextAttributes.BOLD}>
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={creditColor() === "error" ? "error" : "borderSubtle"}
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
- kwhBurnRate={sub() ? estimateDuration(sub()!.kwh_remaining ?? 0, burnRateKwh(sub()!, q()!.snapshot_at)) : "—"}
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 <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
+ );
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"]}>{props.marker}</text>
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: { theme: Theme; percent: number; color: ThemeColor }) {
532
- const pct = Math.max(0, Math.min(100, props.percent))
533
- const filled = Math.max(0, Math.round(pct))
534
- 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));
535
711
 
536
712
  return (
537
- <box width="100%" flexDirection="row" height={1}>
538
- {filled > 0 ? (
539
- <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
+ />
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
- kwhBurnRate: string
555
- monthCost: number
556
- monthKwh: number
557
- lifetimeCost: number
558
- 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;
559
744
  }) {
560
745
  const metrics = (
561
746
  <box width="100%" flexDirection="column" gap={0}>
562
- <MetricRow theme={props.theme} label="Month" value={`${formatCurrency(props.monthCost)} / ${formatNumber(props.monthKwh, 0)} kWh`} />
563
- <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
+ />
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 ?? 0, 1)} kWh`}
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 ?? 0, 1)} kWh`}
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={1}
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: { theme: Theme; label: string; value: string }) {
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 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
+ >
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={primary ? props.theme.primary : props.theme.backgroundElement}
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}>{props.label}</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<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
+ >;
726
906
 
727
- function formatCurrency(n: number): string {
728
- 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)}`;
729
910
  }
730
911
 
731
- function formatNumber(n: number, digits = 2): string {
732
- 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
+ });
733
918
  }
734
919
 
735
- function formatInteger(n: number): string {
736
- return n.toLocaleString()
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(iso: string | null): string {
740
- if (!iso) return "—"
741
- 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())}`;
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
- 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());
768
958
  }
769
959
 
770
960
  function daysElapsedInPeriod(start: string, snapshotAt: string): number {
771
- const diffMs = new Date(snapshotAt).getTime() - new Date(start).getTime()
772
- 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);
773
966
  }
774
967
 
775
968
  function estimateDuration(balance: number, burnRate: number): string {
776
- if (burnRate <= 0) return ""
777
- const days = balance / burnRate
778
- if (days < 1) return "<1d"
779
- if (days < 30) return `~${Math.round(days)}d`
780
- if (days < 365) return `~${Math.round(days / 30)}mo`
781
- 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`;
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;