opencode-providers-balances 0.1.1 → 0.1.2
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 +2 -2
- package/package.json +1 -1
- package/providers.ts +157 -34
- package/tui.tsx +34 -20
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ balance (AITUNNEL).
|
|
|
107
107
|
|
|
108
108
|
| Field | Required | Description |
|
|
109
109
|
| --- | --- | --- |
|
|
110
|
-
| `id` | yes | Stable id; used for `disable` and as the default env
|
|
110
|
+
| `id` | yes | Stable id; used for `disable` and as the base of the default env var (sanitized — see `env`). |
|
|
111
111
|
| `label` | yes | Sidebar label. |
|
|
112
112
|
| `url` | yes | Balance endpoint. |
|
|
113
113
|
| `jsonPath` | no | Dot path into the JSON body. Numeric segments index arrays, e.g. `balance_infos.0.total_balance`. The value must be a number (or numeric string) and is formatted with two decimals. |
|
|
@@ -115,7 +115,7 @@ balance (AITUNNEL).
|
|
|
115
115
|
| `prefixFrom` | no | Derive the prefix from a value: `{ path, map, fallback? }`, e.g. `{ "path": "currency", "map": { "USD": "$" } }`. Takes precedence over `prefix`. |
|
|
116
116
|
| `require` | no | Gate the row: `{ path, equals }`; hidden unless the value at `path` strictly equals `equals`. |
|
|
117
117
|
| `authScheme` | no | Authorization scheme, default `Bearer`. |
|
|
118
|
-
| `env` | no | Env var holding the key, default `<
|
|
118
|
+
| `env` | no | Env var holding the key, default `<SANITIZED_ID>_API_KEY` — the id uppercased with runs of non-alphanumerics turned into `_` (e.g. `router-ai` → `ROUTER_AI_API_KEY`). |
|
|
119
119
|
| `key` | no | Literal key (discouraged — prefer env/integration). |
|
|
120
120
|
| `integration` | no | Id in the V2 SQLite `credential` table. |
|
|
121
121
|
| `configProvider` | no | Provider id in `opencode.jsonc` whose `settings.apiKey` to use. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-providers-balances",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "OpenCode TUI plugin that shows provider account balances in the session sidebar. Providers are configured entirely in opencode.jsonc.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
package/providers.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { readFileSync, existsSync } from "node:fs"
|
|
13
13
|
import { homedir } from "node:os"
|
|
14
|
-
import { join } from "node:path"
|
|
14
|
+
import { join, resolve } from "node:path"
|
|
15
15
|
import { createRequire } from "node:module"
|
|
16
16
|
|
|
17
17
|
// ---------------------------------------------------------------------------
|
|
@@ -58,7 +58,7 @@ export interface Provider {
|
|
|
58
58
|
* - `prefix` / `prefixFrom` fixed or value-derived display prefix.
|
|
59
59
|
*/
|
|
60
60
|
export interface ProviderConfig {
|
|
61
|
-
/** Stable id; also the default env
|
|
61
|
+
/** Stable id; also the base of the default env var and the `disable` key. */
|
|
62
62
|
id: string
|
|
63
63
|
/** Sidebar label. */
|
|
64
64
|
label: string
|
|
@@ -66,7 +66,7 @@ export interface ProviderConfig {
|
|
|
66
66
|
url: string
|
|
67
67
|
/** Authorization scheme; defaults to `Bearer`. */
|
|
68
68
|
authScheme?: string
|
|
69
|
-
/** Env var holding the key; defaults to `<
|
|
69
|
+
/** Env var holding the key; defaults to `<SANITIZED_ID>_API_KEY`. */
|
|
70
70
|
env?: string
|
|
71
71
|
/** Literal key (discouraged — prefer env/integration). */
|
|
72
72
|
key?: string
|
|
@@ -99,8 +99,13 @@ export interface FetchResult {
|
|
|
99
99
|
value: string
|
|
100
100
|
/** True when the provider is configured but the last refresh failed. */
|
|
101
101
|
stale: boolean
|
|
102
|
-
/**
|
|
103
|
-
|
|
102
|
+
/**
|
|
103
|
+
* True when the provider must not be rendered: no usable key, a failed
|
|
104
|
+
* `require` gate, or a response with no numeric value. Distinguishing this
|
|
105
|
+
* from `stale` keeps an intentionally unavailable provider from showing a
|
|
106
|
+
* previous balance marked stale.
|
|
107
|
+
*/
|
|
108
|
+
hidden: boolean
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
export interface BalanceState {
|
|
@@ -122,10 +127,21 @@ export function jsonPathGet(body: unknown, path: string): unknown {
|
|
|
122
127
|
return cur
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* Coerce a value to a finite number, or null. Only a real number or a non-blank
|
|
132
|
+
* numeric string counts; `null`, booleans, arrays, objects and blank strings are
|
|
133
|
+
* rejected rather than coerced (so `Number(null) === 0` can't masquerade as a
|
|
134
|
+
* zero balance).
|
|
135
|
+
*/
|
|
126
136
|
function num(v: unknown): number | null {
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null
|
|
138
|
+
if (typeof v === "string") {
|
|
139
|
+
const t = v.trim()
|
|
140
|
+
if (!t) return null
|
|
141
|
+
const n = Number(t)
|
|
142
|
+
return Number.isFinite(n) ? n : null
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
/** Build a formatter from the declarative rules in a spec. */
|
|
@@ -150,27 +166,67 @@ function buildFormatter(spec: ProviderConfig): (body: unknown) => string | null
|
|
|
150
166
|
|
|
151
167
|
let configCache: { at: number; data: Record<string, unknown> | null } | null = null
|
|
152
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Root directories OpenCode uses for global state, honoring the XDG base
|
|
171
|
+
* directories exactly like OpenCode's own `roots()`: `$XDG_CONFIG_HOME` and
|
|
172
|
+
* `$XDG_DATA_HOME`, falling back to `~/.config` and `~/.local/share` when unset
|
|
173
|
+
* or empty.
|
|
174
|
+
*/
|
|
175
|
+
export function opencodeRoots(app = "opencode"): { config: string; data: string } {
|
|
176
|
+
const home = homedir()
|
|
177
|
+
const configHome = process.env.XDG_CONFIG_HOME || join(home, ".config")
|
|
178
|
+
const dataHome = process.env.XDG_DATA_HOME || join(home, ".local", "share")
|
|
179
|
+
return { config: join(configHome, app), data: join(dataHome, app) }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Candidate global config files in precedence order, honoring OpenCode's
|
|
184
|
+
* `OPENCODE_CONFIG` (explicit file) and `OPENCODE_CONFIG_DIR` (config directory
|
|
185
|
+
* override) before the XDG-derived default.
|
|
186
|
+
*/
|
|
187
|
+
export function configFilePaths(): string[] {
|
|
188
|
+
const dir = process.env.OPENCODE_CONFIG_DIR || opencodeRoots().config
|
|
189
|
+
const files = ["opencode.jsonc", "opencode.json"].map((name) => join(dir, name))
|
|
190
|
+
const explicit = process.env.OPENCODE_CONFIG
|
|
191
|
+
return explicit ? [explicit, ...files] : files
|
|
192
|
+
}
|
|
193
|
+
|
|
153
194
|
function readConfig(): Record<string, unknown> | null {
|
|
154
195
|
const now = Date.now()
|
|
155
196
|
if (configCache && now - configCache.at < CONFIG_CACHE_MS) return configCache.data
|
|
156
197
|
let data: Record<string, unknown> | null = null
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!existsSync(p)) continue
|
|
198
|
+
const inline = process.env.OPENCODE_CONFIG_CONTENT
|
|
199
|
+
if (inline) {
|
|
160
200
|
try {
|
|
161
|
-
data = parseJsonc(
|
|
162
|
-
break
|
|
201
|
+
data = parseJsonc(inline)
|
|
163
202
|
} catch {}
|
|
164
203
|
}
|
|
204
|
+
if (!data) {
|
|
205
|
+
for (const p of configFilePaths()) {
|
|
206
|
+
if (!existsSync(p)) continue
|
|
207
|
+
try {
|
|
208
|
+
data = parseJsonc(readFileSync(p, "utf8"))
|
|
209
|
+
break
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
165
213
|
configCache = { at: now, data }
|
|
166
214
|
return data
|
|
167
215
|
}
|
|
168
216
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Parse JSONC: strip line/block comments and trailing commas, then hand the
|
|
219
|
+
* result to `JSON.parse`. Comments and commas inside string values are left
|
|
220
|
+
* intact. Only `"` delimits strings, matching JSONC.
|
|
221
|
+
*/
|
|
222
|
+
export function parseJsonc(raw: string): Record<string, unknown> {
|
|
223
|
+
return JSON.parse(stripTrailingCommas(stripComments(raw))) as Record<string, unknown>
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Remove line and block comments, ignoring any that appear inside strings. */
|
|
227
|
+
function stripComments(raw: string): string {
|
|
171
228
|
let out = ""
|
|
172
229
|
let inString = false
|
|
173
|
-
let quote = ""
|
|
174
230
|
for (let i = 0; i < raw.length; i++) {
|
|
175
231
|
const ch = raw[i]
|
|
176
232
|
const next = raw[i + 1]
|
|
@@ -179,14 +235,13 @@ function parseJsonc(raw: string): Record<string, unknown> {
|
|
|
179
235
|
if (ch === "\\") {
|
|
180
236
|
out += next ?? ""
|
|
181
237
|
i++
|
|
182
|
-
} else if (ch ===
|
|
238
|
+
} else if (ch === '"') {
|
|
183
239
|
inString = false
|
|
184
240
|
}
|
|
185
241
|
continue
|
|
186
242
|
}
|
|
187
|
-
if (ch === '"'
|
|
243
|
+
if (ch === '"') {
|
|
188
244
|
inString = true
|
|
189
|
-
quote = ch
|
|
190
245
|
out += ch
|
|
191
246
|
continue
|
|
192
247
|
}
|
|
@@ -203,8 +258,38 @@ function parseJsonc(raw: string): Record<string, unknown> {
|
|
|
203
258
|
}
|
|
204
259
|
out += ch
|
|
205
260
|
}
|
|
206
|
-
|
|
207
|
-
|
|
261
|
+
return out
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Drop commas that precede a closing brace/bracket, ignoring string values. */
|
|
265
|
+
function stripTrailingCommas(s: string): string {
|
|
266
|
+
let out = ""
|
|
267
|
+
let inString = false
|
|
268
|
+
for (let i = 0; i < s.length; i++) {
|
|
269
|
+
const ch = s[i]
|
|
270
|
+
if (inString) {
|
|
271
|
+
out += ch
|
|
272
|
+
if (ch === "\\") {
|
|
273
|
+
out += s[i + 1] ?? ""
|
|
274
|
+
i++
|
|
275
|
+
} else if (ch === '"') {
|
|
276
|
+
inString = false
|
|
277
|
+
}
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
if (ch === '"') {
|
|
281
|
+
inString = true
|
|
282
|
+
out += ch
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
if (ch === ",") {
|
|
286
|
+
let j = i + 1
|
|
287
|
+
while (j < s.length && /\s/.test(s[j])) j++
|
|
288
|
+
if (s[j] === "}" || s[j] === "]") continue
|
|
289
|
+
}
|
|
290
|
+
out += ch
|
|
291
|
+
}
|
|
292
|
+
return out
|
|
208
293
|
}
|
|
209
294
|
|
|
210
295
|
// ---------------------------------------------------------------------------
|
|
@@ -279,9 +364,17 @@ function allIntegrationKeys(): Record<string, string> {
|
|
|
279
364
|
return keyCache.keys
|
|
280
365
|
}
|
|
281
366
|
|
|
367
|
+
/** Path to the SQLite credential store, or null when no persistent DB is used. */
|
|
368
|
+
export function databasePath(): string | null {
|
|
369
|
+
const override = process.env.OPENCODE_DB
|
|
370
|
+
const dataDir = opencodeRoots().data
|
|
371
|
+
if (override) return override === ":memory:" ? null : resolve(dataDir, override)
|
|
372
|
+
return join(dataDir, "opencode.db")
|
|
373
|
+
}
|
|
374
|
+
|
|
282
375
|
function readAllCredentials(): Record<string, string> {
|
|
283
|
-
const path =
|
|
284
|
-
if (!existsSync(path)) return {}
|
|
376
|
+
const path = databasePath()
|
|
377
|
+
if (!path || !existsSync(path)) return {}
|
|
285
378
|
const driver = loadSqlite()
|
|
286
379
|
if (!driver) return {}
|
|
287
380
|
let db: any
|
|
@@ -330,7 +423,7 @@ function extractKey(v: unknown): string | null {
|
|
|
330
423
|
}
|
|
331
424
|
|
|
332
425
|
/** Resolve a provider's key from the first source that has one. */
|
|
333
|
-
|
|
426
|
+
function keyForProvider(p: Provider): string | null {
|
|
334
427
|
if (p.literalKey?.trim()) return p.literalKey.trim()
|
|
335
428
|
if (p.integration) {
|
|
336
429
|
const k = allIntegrationKeys()[p.integration]
|
|
@@ -349,6 +442,21 @@ export function keyForProvider(p: Provider): string | null {
|
|
|
349
442
|
// Provider construction
|
|
350
443
|
// ---------------------------------------------------------------------------
|
|
351
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Derive a conventional, settable env var name from a provider id:
|
|
447
|
+
* uppercase, runs of characters that are not `A-Z0-9` collapsed to `_`, and a
|
|
448
|
+
* leading `_` added when the result would otherwise start with a digit. So
|
|
449
|
+
* `router-ai` -> `ROUTER_AI_API_KEY` rather than `ROUTER-AI_API_KEY`.
|
|
450
|
+
*/
|
|
451
|
+
function defaultEnvVar(id: string): string {
|
|
452
|
+
const base = id
|
|
453
|
+
.toUpperCase()
|
|
454
|
+
.replace(/[^A-Z0-9]+/g, "_")
|
|
455
|
+
.replace(/^_+|_+$/g, "")
|
|
456
|
+
const safe = base === "" ? "PROVIDER" : /^[0-9]/.test(base) ? `_${base}` : base
|
|
457
|
+
return `${safe}_API_KEY`
|
|
458
|
+
}
|
|
459
|
+
|
|
352
460
|
/** Convert a declarative spec into an executable provider. */
|
|
353
461
|
function toProvider(spec: ProviderConfig): Provider {
|
|
354
462
|
return {
|
|
@@ -356,7 +464,7 @@ function toProvider(spec: ProviderConfig): Provider {
|
|
|
356
464
|
label: spec.label,
|
|
357
465
|
url: spec.url,
|
|
358
466
|
authScheme: spec.authScheme,
|
|
359
|
-
env: spec.env ??
|
|
467
|
+
env: spec.env ?? defaultEnvVar(spec.id),
|
|
360
468
|
integration: spec.integration,
|
|
361
469
|
configProvider: spec.configProvider,
|
|
362
470
|
literalKey: spec.key,
|
|
@@ -394,7 +502,7 @@ export function buildProviders(options?: BalancesOptions): Provider[] {
|
|
|
394
502
|
export async function fetchProvider(p: Provider): Promise<FetchResult> {
|
|
395
503
|
const key = keyForProvider(p)
|
|
396
504
|
// Hidden rather than stale when unconfigured.
|
|
397
|
-
if (!key) return { value: "", stale: false,
|
|
505
|
+
if (!key) return { value: "", stale: false, hidden: true }
|
|
398
506
|
|
|
399
507
|
const ctrl = new AbortController()
|
|
400
508
|
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
|
|
@@ -404,11 +512,13 @@ export async function fetchProvider(p: Provider): Promise<FetchResult> {
|
|
|
404
512
|
headers: { Authorization: `${scheme} ${key}` },
|
|
405
513
|
signal: ctrl.signal,
|
|
406
514
|
})
|
|
407
|
-
if (!res.ok) return { value: "", stale: true,
|
|
515
|
+
if (!res.ok) return { value: "", stale: true, hidden: false }
|
|
408
516
|
const value = p.format(await res.json())
|
|
409
|
-
|
|
517
|
+
// A response that parses but yields no value hides the row; it is not a
|
|
518
|
+
// failed fetch, so it must not resurface as a stale balance.
|
|
519
|
+
return value ? { value, stale: false, hidden: false } : { value: "", stale: false, hidden: true }
|
|
410
520
|
} catch {
|
|
411
|
-
return { value: "", stale: true,
|
|
521
|
+
return { value: "", stale: true, hidden: false }
|
|
412
522
|
} finally {
|
|
413
523
|
clearTimeout(timer)
|
|
414
524
|
}
|
|
@@ -416,7 +526,7 @@ export async function fetchProvider(p: Provider): Promise<FetchResult> {
|
|
|
416
526
|
|
|
417
527
|
/**
|
|
418
528
|
* Merge fresh results into state. Successful values replace; a failed refresh
|
|
419
|
-
* keeps the previous value marked stale; a
|
|
529
|
+
* keeps the previous value marked stale; a hidden provider is removed.
|
|
420
530
|
*/
|
|
421
531
|
export function collect(
|
|
422
532
|
state: Map<ProviderKey, BalanceState>,
|
|
@@ -425,7 +535,7 @@ export function collect(
|
|
|
425
535
|
): void {
|
|
426
536
|
providers.forEach((p, i) => {
|
|
427
537
|
const fresh = results[i]
|
|
428
|
-
if (!fresh || fresh.
|
|
538
|
+
if (!fresh || fresh.hidden) {
|
|
429
539
|
state.delete(p.id)
|
|
430
540
|
return
|
|
431
541
|
}
|
|
@@ -439,12 +549,25 @@ export function collect(
|
|
|
439
549
|
})
|
|
440
550
|
}
|
|
441
551
|
|
|
442
|
-
|
|
443
|
-
|
|
552
|
+
/** One renderable balance line: a provider that currently has a value. */
|
|
553
|
+
export interface BalanceRow {
|
|
554
|
+
id: ProviderKey
|
|
555
|
+
label: string
|
|
556
|
+
value: string
|
|
557
|
+
stale: boolean
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Providers with a current value, in provider order. */
|
|
561
|
+
export function balanceRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): BalanceRow[] {
|
|
562
|
+
const rows: BalanceRow[] = []
|
|
444
563
|
for (const p of providers) {
|
|
445
564
|
const s = state.get(p.id)
|
|
446
565
|
if (!s?.value) continue
|
|
447
|
-
rows.push(
|
|
566
|
+
rows.push({ id: p.id, label: p.label, value: s.value, stale: s.stale })
|
|
448
567
|
}
|
|
449
568
|
return rows
|
|
450
569
|
}
|
|
570
|
+
|
|
571
|
+
export function renderRows(state: Map<ProviderKey, BalanceState>, providers: Provider[]): string[] {
|
|
572
|
+
return balanceRows(state, providers).map((r) => `${r.stale ? "!" : "•"} ${r.label} ${r.value}`)
|
|
573
|
+
}
|
package/tui.tsx
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
-
import { createSignal } from "solid-js"
|
|
3
|
+
import { createSignal, For } from "solid-js"
|
|
4
4
|
import {
|
|
5
|
+
balanceRows,
|
|
5
6
|
buildProviders,
|
|
6
7
|
collect,
|
|
7
8
|
DEFAULT_REFRESH_MINUTES,
|
|
8
9
|
fetchProvider,
|
|
9
10
|
loadOptionsFromConfig,
|
|
10
|
-
|
|
11
|
+
type BalanceRow,
|
|
11
12
|
type BalanceState,
|
|
12
13
|
type BalancesOptions,
|
|
13
14
|
type Provider,
|
|
14
15
|
type ProviderKey,
|
|
15
16
|
} from "./providers"
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
* Resolve options. OpenCode V2 beta passes plugin options to the server
|
|
19
|
-
* entrypoint but not to the TUI entrypoint, so fall back to reading our own
|
|
20
|
-
* options from opencode.jsonc when the context gives us nothing.
|
|
21
|
-
*/
|
|
18
|
+
/** Resolve options, falling back to opencode.jsonc when the context gives none. */
|
|
22
19
|
function resolveOptions(contextOptions: unknown): BalancesOptions {
|
|
23
20
|
const fromContext = (contextOptions ?? {}) as BalancesOptions
|
|
24
21
|
if (Array.isArray(fromContext.providers) && fromContext.providers.length > 0) return fromContext
|
|
@@ -34,37 +31,54 @@ function resolveOptions(contextOptions: unknown): BalancesOptions {
|
|
|
34
31
|
|
|
35
32
|
export default Plugin.define({
|
|
36
33
|
id: "providers-balances.tui",
|
|
37
|
-
|
|
34
|
+
setup(context) {
|
|
38
35
|
const options = resolveOptions(context.options)
|
|
39
36
|
const providers: Provider[] = buildProviders(options)
|
|
40
37
|
const refreshMs = Math.max(1, options.refreshMinutes ?? DEFAULT_REFRESH_MINUTES) * 60_000
|
|
41
38
|
|
|
42
39
|
const state = new Map<ProviderKey, BalanceState>()
|
|
43
|
-
const [rows, setRows] = createSignal(
|
|
40
|
+
const [rows, setRows] = createSignal<BalanceRow[]>([])
|
|
41
|
+
const [loading, setLoading] = createSignal(providers.length > 0)
|
|
42
|
+
let stopped = false
|
|
44
43
|
|
|
45
44
|
async function refresh() {
|
|
46
45
|
const results = await Promise.all(providers.map((p) => fetchProvider(p)))
|
|
46
|
+
if (stopped) return
|
|
47
47
|
collect(state, providers, results)
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
setRows(balanceRows(state, providers))
|
|
49
|
+
setLoading(false)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Fetch before registering so the first render already has values.
|
|
53
|
-
await refresh()
|
|
54
|
-
|
|
55
52
|
const unregister = context.ui.slot({
|
|
56
53
|
append: "sidebar.content",
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
// Show a placeholder during the first fetch, then either the rows or
|
|
55
|
+
// nothing at all (never a bare heading).
|
|
56
|
+
render: () => {
|
|
57
|
+
const list = rows()
|
|
58
|
+
if (!list.length && !loading()) return null
|
|
59
|
+
return (
|
|
60
|
+
<text>
|
|
61
|
+
<b>Balances</b>
|
|
62
|
+
<For each={list}>
|
|
63
|
+
{(r) => (
|
|
64
|
+
<span style={{ fg: r.stale ? context.theme.text.feedback.warning.base : context.theme.text.base }}>
|
|
65
|
+
{`\n${r.stale ? "!" : "•"} ${r.label} ${r.value}`}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
68
|
+
</For>
|
|
69
|
+
{loading() && !list.length ? "\n…" : ""}
|
|
70
|
+
</text>
|
|
71
|
+
)
|
|
72
|
+
},
|
|
63
73
|
})
|
|
64
74
|
|
|
75
|
+
// Register first, then fetch in the background: a slow or unreachable
|
|
76
|
+
// endpoint must not delay setup or the first render.
|
|
77
|
+
void refresh()
|
|
65
78
|
const timer = setInterval(() => void refresh(), refreshMs)
|
|
66
79
|
|
|
67
80
|
return () => {
|
|
81
|
+
stopped = true
|
|
68
82
|
clearInterval(timer)
|
|
69
83
|
unregister()
|
|
70
84
|
}
|