@rizal72/oc-budget 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizal72/oc-budget",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "How much credit is left on the account behind the model you are using: the Go subscription's windows, the Zen balance, and the pay-per-use wallets, in the sidebar, the footer and the header",
6
6
  "repository": {
@@ -20,15 +20,15 @@
20
20
  },
21
21
  "exports": {
22
22
  ".": "./index.ts",
23
- "./tui": "./tui.tsx",
23
+ "./tui": "./tui.js",
24
24
  "./rpc": "./rpc.ts",
25
25
  "./gui-experimental": "./dist/gui-experimental.cjs"
26
26
  },
27
27
  "files": [
28
28
  "*.ts",
29
- "*.tsx",
29
+ "!*.test.ts",
30
+ "tui.js",
30
31
  "README.md",
31
- "dist",
32
- "!*.test.ts"
32
+ "dist"
33
33
  ]
34
34
  }
package/tui.js ADDED
@@ -0,0 +1,656 @@
1
+ import { createComponent as _$createComponent } from "@opentui/solid";
2
+ import { effect as _$effect } from "@opentui/solid";
3
+ import { memo as _$memo } from "@opentui/solid";
4
+ import { insert as _$insert } from "@opentui/solid";
5
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
6
+ import { insertNode as _$insertNode } from "@opentui/solid";
7
+ import { setProp as _$setProp } from "@opentui/solid";
8
+ import { createElement as _$createElement } from "@opentui/solid";
9
+ // oc-budget — TUI half.
10
+ //
11
+ // Two slots and one command, all reading the same snapshot the server half publishes:
12
+ //
13
+ // sidebar.content the rich view: every wallet, the bars, the resets, the peak
14
+ // prompt.footer.status the paying wallet alone — the context total is oc-context's
15
+ // /budget the full report in a dialog (a bar for each Go window, the
16
+ // reset times, the peak regime)
17
+ //
18
+ // The footer is only appended to, never taken over: that row also holds the project folder,
19
+ // the working spinner and the esc hint, and giving them up for one string is a bad trade.
20
+ //
21
+ // The numbers come from the server plugin over RPC; nothing is fetched here and no
22
+ // credential is ever handled here. Which wallet *pays* each half decides from the model of its
23
+ // own surface: the session's here, the composer's in the window — the two pickers can disagree.
24
+ //
25
+ // Solid owns the reactivity, and a component body runs exactly once: every value that
26
+ // depends on the snapshot or on the model is read inside a `createMemo` (or inside JSX),
27
+ // never in the body. Reading it in the body is how the first version froze on
28
+ // "reading…" for ever.
29
+ //
30
+ // Colours come from the theme, never from hard-coded hex: the theme has no
31
+ // success/warning/error tokens for plugins, so the alert levels are taken from the
32
+ // nearest semantic hues it does expose (`diff.added` / `diff.removed`) and fall back to
33
+ // fixed values only if a theme does not define them.
34
+ import { Plugin } from "@opencode/plugin/tui";
35
+ import { createEffect, createMemo, createRoot, createSignal, Show } from "solid-js";
36
+ // The tariff is the same fact on both surfaces: `peak.ts` holds it, and these names are the ones the
37
+ // widget was written with.
38
+ import { clock, doubles as peakDoubles, isPeak as isDeepseekPeak, nextChange as nextRegimeChange } from "./peak.ts";
39
+ import { Budget } from "./rpc.ts";
40
+ import { DS_WARN_USD, GO_WARN_PERCENT, OR_WARN_USD, ZEN_WARN_USD, ageText, connected, countdown, payingWallet, windowPercent } from "./budget.ts";
41
+ const REFRESH_MS = 5 * 60 * 1000;
42
+
43
+ /** A stalled RPC must not leave the widget saying "reading…" for ever. The timer is cleared with the
44
+ * answer: a poll every five minutes would otherwise keep a fifteen-second timer around for nothing. */
45
+ const RPC_TIMEOUT_MS = 15_000;
46
+ function withTimeout(promise, ms) {
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => reject(new Error(`rpc timed out after ${ms}ms`)), ms);
49
+ promise.then(value => {
50
+ clearTimeout(timer);
51
+ resolve(value);
52
+ }, error => {
53
+ clearTimeout(timer);
54
+ reject(error);
55
+ });
56
+ });
57
+ }
58
+
59
+ /** Last-resort colours, used only when the active theme defines none of the token
60
+ * candidates below. */
61
+ const FALLBACK_GREEN = "#3fb950";
62
+ const FALLBACK_YELLOW = "#d29922";
63
+ const FALLBACK_RED = "#f85149";
64
+ const FALLBACK_DIM = "#8b949e";
65
+ const FALLBACK_TEXT = "#c9d1d9";
66
+ const FALLBACK_ACCENT = "#79c0ff";
67
+
68
+ /** md-speedometer, the budget slot's mark; mdi trending-up, the peak marker. Carried in
69
+ * the text rather than the row's icon so the widget reads the same wherever it lands. */
70
+ const BUDGET_GLYPH = "\u{f04c5}";
71
+ const PEAK_GLYPH = "\u{f0535}";
72
+ function bar(percent, cells = 10) {
73
+ const filled = Math.max(0, Math.min(cells, Math.round(percent / 100 * cells)));
74
+ return "█".repeat(filled) + "░".repeat(cells - filled);
75
+ }
76
+ function usd(value) {
77
+ if (typeof value !== "number" || !Number.isFinite(value)) return "—";
78
+ return `$${value.toFixed(2)}`;
79
+ }
80
+
81
+ /** A percent as the panel prints it — a dash, never a fake zero, when there is no number. */
82
+ function percentText(percent) {
83
+ return percent === null ? "—" : `${Math.round(percent)}%`;
84
+ }
85
+ export default Plugin.define({
86
+ id: "oc-budget.tui",
87
+ setup(context) {
88
+ // ---------------------------------------------------------------------------
89
+ // Colours, resolved from the theme once, at setup
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** First candidate path the theme actually defines. The value is handed to `fg`
93
+ * as-is: theme colours are the renderer's own colour type, not always hex strings. */
94
+ const themeColour = (...paths) => {
95
+ for (const path of paths) {
96
+ let node = context.theme;
97
+ for (const key of path) node = node?.[key];
98
+ if (node !== undefined && node !== null) return node;
99
+ }
100
+ return undefined;
101
+ };
102
+
103
+ // Colours come from the theme, never from a hex literal: each role takes the first token the
104
+ // theme defines. Green and red lean on the diff pair, the one hue every theme guarantees;
105
+ // yellow takes the theme's own warning semantics first, because no syntax hue is reliably
106
+ // warm. Hex only when a theme defines none of the candidates.
107
+ const GREEN = themeColour(["syntax", "string"], ["diff", "text", "added"], ["text", "feedback", "success", "base"]) ?? FALLBACK_GREEN;
108
+ const RED = themeColour(["diff", "text", "removed"], ["syntax", "keyword"], ["text", "feedback", "error", "base"]) ?? FALLBACK_RED;
109
+ const YELLOW = themeColour(["text", "feedback", "warning", "base"], ["syntax", "type"], ["syntax", "number"]) ?? FALLBACK_YELLOW;
110
+ const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM;
111
+ /** The heading and the glyph read as ordinary text, not as a muted aside. */
112
+ const TEXT = themeColour(["text", "base"]) ?? FALLBACK_TEXT;
113
+ /** The one accent for a *value* rather than a state — the workspace name, the same blue as the
114
+ * branch in oc-git: `syntax.function` is the identity hue in most themes. */
115
+ const ACCENT = themeColour(["syntax", "function"], ["syntax", "variable"], ["text", "base"]) ?? FALLBACK_ACCENT;
116
+ const rpc = context.client.rpc(Budget);
117
+ const [snapshot, setSnapshot] = createRoot(() => createSignal(null));
118
+ const [ready, setReady] = createRoot(() => createSignal(false));
119
+ /** Why the last read failed. Kept, because a failed read used to be indistinguishable from a
120
+ * fresh one: the panel showed the previous numbers and said nothing (eleven hours, 2026-10-27). */
121
+ const [failure, setFailure] = createRoot(() => createSignal(null));
122
+ const load = async force => {
123
+ try {
124
+ const next = await withTimeout(rpc.snapshot(force ? {
125
+ force: true
126
+ } : {}), RPC_TIMEOUT_MS);
127
+ setSnapshot(next);
128
+ setFailure(null);
129
+ } catch (error) {
130
+ // Keep the last good snapshot rather than blanking the widget — but say so.
131
+ setFailure(error instanceof Error ? error.message : String(error));
132
+ } finally {
133
+ setReady(true);
134
+ }
135
+ };
136
+ void load(true);
137
+ const timer = setInterval(() => void load(false), REFRESH_MS);
138
+
139
+ // An account switch is a credential change on the server; picking it up from the
140
+ // event stream makes the widget follow the switch at once instead of waiting for
141
+ // the next poll. The exact event names are not relied on — anything mentioning a
142
+ // credential or an integration re-reads (the server caches, so a stray one is cheap).
143
+ let unsubscribe;
144
+ try {
145
+ unsubscribe = context.data.listen(event => {
146
+ const type = String(event?.details?.type ?? event?.type ?? "");
147
+ if (/credential|integration/.test(type)) void load(true);
148
+ });
149
+ } catch {
150
+ // No event stream available: the poll still covers it.
151
+ }
152
+
153
+ // A model switch should land immediately: the wallet that pays can change with it.
154
+ let lastModelKey = "";
155
+ createRoot(() => {
156
+ createEffect(() => {
157
+ const current = context.ui.model.current();
158
+ const key = `${current?.providerID ?? ""}/${current?.modelID ?? ""}`;
159
+ if (key === lastModelKey) return;
160
+ lastModelKey = key;
161
+ void load(true);
162
+ });
163
+ });
164
+ const model = () => context.ui.model.current();
165
+
166
+ /** Go window colour: green normally, yellow from the alert threshold, red once the window
167
+ * is exhausted — the quota is spent and the gateway has fallen back to the Zen balance,
168
+ * the same "nothing left" the other wallets report in red at zero. */
169
+ const goColor = percent => {
170
+ if (percent === null) return DIM;
171
+ if (percent >= 100) return RED;
172
+ return percent >= GO_WARN_PERCENT ? YELLOW : GREEN;
173
+ };
174
+ const balanceColor = (value, warn) => {
175
+ if (typeof value !== "number") return DIM;
176
+ if (value <= 0) return RED;
177
+ return value <= warn ? YELLOW : GREEN;
178
+ };
179
+
180
+ /** The console's figure: spendable credit, which is the balance unless it went under. */
181
+ const zenCredit = zen => zen?.available ?? zen?.balance;
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Rows
185
+ // ---------------------------------------------------------------------------
186
+
187
+ const Sidebar = () => {
188
+ // A memo, not the body: the body runs once, the memo re-runs on every
189
+ // snapshot/model change and rebuilds the rows from the current values.
190
+ const rows = createMemo(() => {
191
+ const snap = snapshot();
192
+ // Two questions, two functions (`peak.ts`). This block answers "is the tariff in its doubled
193
+ // window": it marks GO and DS whatever is paying, because the mark is the reminder not to
194
+ // switch *to* them now. `doubles` answers "is the model in front of me paying double", and
195
+ // that is the footer's and the capsule's job.
196
+ const atPeak = isDeepseekPeak(clock()) ? PEAK_GLYPH : "";
197
+ const goPercent = windowPercent(snap?.go?.monthly);
198
+ const out = [];
199
+ if (!ready()) out.push((() => {
200
+ var _el$ = _$createElement("text");
201
+ _$insertNode(_el$, _$createTextNode(`reading…`));
202
+ _$setProp(_el$, "fg", DIM);
203
+ return _el$;
204
+ })());
205
+ // A reading older than two polls is not fresh, and saying so is the difference between
206
+ // "the numbers" and "the numbers from an hour ago".
207
+ const readAt = snap?.at;
208
+ if (typeof readAt === "number" && Date.now() - readAt > 2 * REFRESH_MS) {
209
+ out.push((() => {
210
+ var _el$3 = _$createElement("text"),
211
+ _el$4 = _$createTextNode(`stale `);
212
+ _$insertNode(_el$3, _el$4);
213
+ _$setProp(_el$3, "fg", YELLOW);
214
+ _$insert(_el$3, () => ageText(Date.now() - readAt), null);
215
+ return _el$3;
216
+ })());
217
+ }
218
+ if (ready() && !snap) out.push((() => {
219
+ var _el$5 = _$createElement("text");
220
+ _$insertNode(_el$5, _$createTextNode(`budget —`));
221
+ _$setProp(_el$5, "fg", RED);
222
+ return _el$5;
223
+ })());
224
+ if (failure()) out.push((() => {
225
+ var _el$7 = _$createElement("text");
226
+ _$setProp(_el$7, "fg", DIM);
227
+ _$insert(_el$7, failure);
228
+ return _el$7;
229
+ })());
230
+ // No wallet at all. The title above is drawn either way, so without this line it would be a
231
+ // promise with nothing under it; one muted line says what the account is, and `/budget`
232
+ // carries the command that changes it.
233
+ if (ready() && snap && !connected(snap)) {
234
+ out.push((() => {
235
+ var _el$8 = _$createElement("text");
236
+ _$insertNode(_el$8, _$createTextNode(`no provider connected`));
237
+ _$setProp(_el$8, "fg", DIM);
238
+ return _el$8;
239
+ })());
240
+ }
241
+ if (snap?.go && !snap.go.error) {
242
+ out.push((() => {
243
+ var _el$0 = _$createElement("box"),
244
+ _el$1 = _$createElement("text"),
245
+ _el$11 = _$createElement("text"),
246
+ _el$13 = _$createElement("text");
247
+ _$insertNode(_el$0, _el$1);
248
+ _$insertNode(_el$0, _el$11);
249
+ _$insertNode(_el$0, _el$13);
250
+ _$setProp(_el$0, "flexDirection", "row");
251
+ _$insertNode(_el$1, _$createTextNode(`GO`));
252
+ _$insertNode(_el$11, _$createTextNode(` `));
253
+ _$insert(_el$13, () => goPercent === null ? "—" : `${bar(goPercent)} ${percentText(goPercent)}`);
254
+ _$insert(_el$0, atPeak ? (() => {
255
+ var _el$14 = _$createElement("text"),
256
+ _el$15 = _$createTextNode(` 󰔵`);
257
+ _$insertNode(_el$14, _el$15);
258
+ _$setProp(_el$14, "fg", RED);
259
+ return _el$14;
260
+ })() : null, null);
261
+ _$effect(_p$ => {
262
+ var _v$ = goColor(goPercent),
263
+ _v$2 = goColor(goPercent);
264
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$1, "fg", _v$, _p$.e));
265
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$13, "fg", _v$2, _p$.t));
266
+ return _p$;
267
+ }, {
268
+ e: undefined,
269
+ t: undefined
270
+ });
271
+ return _el$0;
272
+ })());
273
+ // One line per window, its reset on the same line: three counters side by side plus three
274
+ // countdowns would need ~45 columns and this panel does not know the sidebar's width, so
275
+ // the block grows down instead of across. The percent is padded to five so the resets line
276
+ // up; the widest row is `mo 100% reset 24d 3h`, 21 columns.
277
+ const windowRow = (label, percent, resetsAt) => {
278
+ const reset = countdown(resetsAt);
279
+ return (() => {
280
+ var _el$17 = _$createElement("box"),
281
+ _el$18 = _$createElement("text"),
282
+ _el$19 = _$createTextNode(` `),
283
+ _el$20 = _$createElement("text");
284
+ _$insertNode(_el$17, _el$18);
285
+ _$insertNode(_el$17, _el$20);
286
+ _$setProp(_el$17, "flexDirection", "row");
287
+ _$insertNode(_el$18, _el$19);
288
+ _$setProp(_el$18, "fg", DIM);
289
+ _$insert(_el$18, label, _el$19);
290
+ _$insert(_el$20, () => percentText(percent).padEnd(5));
291
+ _$insert(_el$17, reset ? (() => {
292
+ var _el$21 = _$createElement("text"),
293
+ _el$22 = _$createTextNode(`reset `);
294
+ _$insertNode(_el$21, _el$22);
295
+ _$setProp(_el$21, "fg", DIM);
296
+ _$insert(_el$21, reset, null);
297
+ return _el$21;
298
+ })() : null, null);
299
+ _$effect(_$p => _$setProp(_el$20, "fg", goColor(percent), _$p));
300
+ return _el$17;
301
+ })();
302
+ };
303
+ out.push(windowRow("5h", windowPercent(snap.go.rolling), snap.go.rolling?.resetsAt));
304
+ out.push(windowRow("wk", windowPercent(snap.go.weekly), snap.go.weekly?.resetsAt));
305
+ out.push(windowRow("mo", goPercent, snap.go.monthly?.resetsAt));
306
+ // After the reset, as a line of its own: the label dim, the name in the accent oc-git uses
307
+ // for the branch.
308
+ if (snap.zen?.org && (snap.zen.peers ?? 0) > 1) {
309
+ out.push((() => {
310
+ var _el$23 = _$createElement("box"),
311
+ _el$24 = _$createElement("text"),
312
+ _el$26 = _$createElement("text");
313
+ _$insertNode(_el$23, _el$24);
314
+ _$insertNode(_el$23, _el$26);
315
+ _$setProp(_el$23, "flexDirection", "row");
316
+ _$insertNode(_el$24, _$createTextNode(`workspace: `));
317
+ _$setProp(_el$24, "fg", DIM);
318
+ _$setProp(_el$26, "fg", ACCENT);
319
+ _$insert(_el$26, () => snap.zen.org);
320
+ return _el$23;
321
+ })());
322
+ }
323
+ // A dim rule closes the Go block, so the wallets below read as a separate group. Fixed, not
324
+ // measured: the panel does not know how wide the sidebar is, and 20 columns is the width every
325
+ // line clears short of exhaustion — a window row at 100% with a day-scale reset reaches 21.
326
+ out.push((() => {
327
+ var _el$27 = _$createElement("text");
328
+ _$insertNode(_el$27, _$createTextNode(`────────────────────`));
329
+ _$setProp(_el$27, "fg", DIM);
330
+ return _el$27;
331
+ })());
332
+ } else if (snap?.go?.error) {
333
+ out.push((() => {
334
+ var _el$29 = _$createElement("text");
335
+ _$insertNode(_el$29, _$createTextNode(`GO —`));
336
+ _$setProp(_el$29, "fg", DIM);
337
+ return _el$29;
338
+ })());
339
+ out.push((() => {
340
+ var _el$31 = _$createElement("text");
341
+ _$setProp(_el$31, "fg", DIM);
342
+ _$insert(_el$31, () => snap.go.error);
343
+ return _el$31;
344
+ })());
345
+ }
346
+ if (snap?.ds && !snap.ds.error) {
347
+ out.push((() => {
348
+ var _el$32 = _$createElement("box"),
349
+ _el$33 = _$createElement("text"),
350
+ _el$34 = _$createTextNode(`DS `);
351
+ _$insertNode(_el$32, _el$33);
352
+ _$setProp(_el$32, "flexDirection", "row");
353
+ _$insertNode(_el$33, _el$34);
354
+ _$insert(_el$33, () => usd(snap.ds.balance), null);
355
+ _$insert(_el$32, atPeak ? (() => {
356
+ var _el$35 = _$createElement("text"),
357
+ _el$36 = _$createTextNode(` 󰔵`);
358
+ _$insertNode(_el$35, _el$36);
359
+ _$setProp(_el$35, "fg", RED);
360
+ return _el$35;
361
+ })() : null, null);
362
+ _$effect(_$p => _$setProp(_el$33, "fg", balanceColor(snap.ds.balance, DS_WARN_USD), _$p));
363
+ return _el$32;
364
+ })());
365
+ } else if (snap?.ds?.error) {
366
+ out.push((() => {
367
+ var _el$38 = _$createElement("text");
368
+ _$insertNode(_el$38, _$createTextNode(`DS —`));
369
+ _$setProp(_el$38, "fg", DIM);
370
+ return _el$38;
371
+ })());
372
+ out.push((() => {
373
+ var _el$40 = _$createElement("text");
374
+ _$setProp(_el$40, "fg", DIM);
375
+ _$insert(_el$40, () => snap.ds.error);
376
+ return _el$40;
377
+ })());
378
+ }
379
+ if (snap?.or && !snap.or.error) {
380
+ out.push((() => {
381
+ var _el$41 = _$createElement("box"),
382
+ _el$42 = _$createElement("text"),
383
+ _el$43 = _$createTextNode(`OR `);
384
+ _$insertNode(_el$41, _el$42);
385
+ _$setProp(_el$41, "flexDirection", "row");
386
+ _$insertNode(_el$42, _el$43);
387
+ _$insert(_el$42, () => usd(snap.or.balance), null);
388
+ _$effect(_$p => _$setProp(_el$42, "fg", balanceColor(snap.or.balance, OR_WARN_USD), _$p));
389
+ return _el$41;
390
+ })());
391
+ } else if (snap?.or?.error) {
392
+ out.push((() => {
393
+ var _el$44 = _$createElement("text");
394
+ _$insertNode(_el$44, _$createTextNode(`OR —`));
395
+ _$setProp(_el$44, "fg", DIM);
396
+ return _el$44;
397
+ })());
398
+ out.push((() => {
399
+ var _el$46 = _$createElement("text");
400
+ _$setProp(_el$46, "fg", DIM);
401
+ _$insert(_el$46, () => snap.or.error);
402
+ return _el$46;
403
+ })());
404
+ }
405
+ if (snap?.zen && !snap.zen.error) {
406
+ out.push((() => {
407
+ var _el$47 = _$createElement("text"),
408
+ _el$48 = _$createTextNode(`ZEN `);
409
+ _$insertNode(_el$47, _el$48);
410
+ _$insert(_el$47, () => usd(zenCredit(snap.zen)), null);
411
+ _$effect(_$p => _$setProp(_el$47, "fg", balanceColor(zenCredit(snap.zen), ZEN_WARN_USD), _$p));
412
+ return _el$47;
413
+ })());
414
+ } else if (snap?.zen?.error) {
415
+ out.push((() => {
416
+ var _el$49 = _$createElement("text");
417
+ _$insertNode(_el$49, _$createTextNode(`ZEN —`));
418
+ _$setProp(_el$49, "fg", DIM);
419
+ return _el$49;
420
+ })());
421
+ out.push((() => {
422
+ var _el$51 = _$createElement("text");
423
+ _$setProp(_el$51, "fg", DIM);
424
+ _$insert(_el$51, () => snap.zen.error);
425
+ return _el$51;
426
+ })());
427
+ }
428
+ return out;
429
+ });
430
+ return (() => {
431
+ var _el$52 = _$createElement("box"),
432
+ _el$53 = _$createElement("text"),
433
+ _el$54 = _$createElement("span"),
434
+ _el$55 = _$createTextNode(`󰓅 `),
435
+ _el$57 = _$createElement("b");
436
+ _$insertNode(_el$52, _el$53);
437
+ _$setProp(_el$52, "flexDirection", "column");
438
+ _$insertNode(_el$53, _el$54);
439
+ _$insertNode(_el$53, _el$57);
440
+ _$setProp(_el$53, "fg", TEXT);
441
+ _$insertNode(_el$54, _el$55);
442
+ _$setProp(_el$54, "style", {
443
+ fg: DIM
444
+ });
445
+ _$insertNode(_el$57, _$createTextNode(`Budget`));
446
+ _$insert(_el$52, rows, null);
447
+ return _el$52;
448
+ })();
449
+ };
450
+
451
+ /** Appended to the footer, and the footer stays OpenCode's own: the folder (which is
452
+ * clickable), the working spinner, the esc hint, the usage, the cost and the
453
+ * shortcuts are all native. Taking that row over was tried and gave every one of
454
+ * them up for a single string, so what we add is the paying wallet alone. */
455
+ const Footer = () => {
456
+ const budget = createMemo(() => {
457
+ const snap = snapshot();
458
+ const wallet = payingWallet(model(), snap);
459
+ if (wallet === "none") return undefined;
460
+ // The wallet the model pays with has no connection: nothing to say, and a dash would
461
+ // claim a reading. A snapshot that has not arrived yet is not this case.
462
+ if (snap && !snap[wallet]) return undefined;
463
+ if (wallet === "go") {
464
+ const percent = windowPercent(snap?.go?.monthly);
465
+ return {
466
+ color: goColor(percent),
467
+ body: percent === null ? "GO —" : `GO ${Math.round(percent)}%`,
468
+ marker: peakDoubles(model(), wallet, clock()) ? PEAK_GLYPH : ""
469
+ };
470
+ }
471
+ if (wallet === "zen") {
472
+ return {
473
+ color: balanceColor(zenCredit(snap?.zen), ZEN_WARN_USD),
474
+ body: `ZEN ${snap?.zen?.error ? "—" : usd(zenCredit(snap?.zen))}`,
475
+ marker: ""
476
+ };
477
+ }
478
+ if (wallet === "ds") {
479
+ return {
480
+ color: balanceColor(snap?.ds?.balance, DS_WARN_USD),
481
+ body: `DS ${snap?.ds?.error ? "—" : usd(snap?.ds?.balance)}`,
482
+ marker: peakDoubles(model(), wallet, clock()) ? PEAK_GLYPH : ""
483
+ };
484
+ }
485
+ return {
486
+ color: balanceColor(snap?.or?.balance, OR_WARN_USD),
487
+ body: `OR ${snap?.or?.error ? "—" : usd(snap?.or?.balance)}`,
488
+ marker: ""
489
+ };
490
+ });
491
+ return (() => {
492
+ var _el$59 = _$createElement("box");
493
+ _$setProp(_el$59, "flexDirection", "row");
494
+ _$setProp(_el$59, "gap", 2);
495
+ _$setProp(_el$59, "flexShrink", 0);
496
+ _$insert(_el$59, _$createComponent(Show, {
497
+ get when() {
498
+ return budget();
499
+ },
500
+ children: value => (() => {
501
+ var _el$60 = _$createElement("box"),
502
+ _el$61 = _$createElement("text"),
503
+ _el$62 = _$createTextNode(`󰓅 `),
504
+ _el$64 = _$createElement("text");
505
+ _$insertNode(_el$60, _el$61);
506
+ _$insertNode(_el$60, _el$64);
507
+ _$setProp(_el$60, "flexDirection", "row");
508
+ _$setProp(_el$60, "flexShrink", 0);
509
+ _$insertNode(_el$61, _el$62);
510
+ _$setProp(_el$61, "fg", DIM);
511
+ _$setProp(_el$61, "wrapMode", "none");
512
+ _$setProp(_el$64, "wrapMode", "none");
513
+ _$insert(_el$64, () => value().body);
514
+ _$insert(_el$60, _$createComponent(Show, {
515
+ get when() {
516
+ return value().marker;
517
+ },
518
+ children: marker => (() => {
519
+ var _el$65 = _$createElement("text"),
520
+ _el$66 = _$createTextNode(` `);
521
+ _$insertNode(_el$65, _el$66);
522
+ _$setProp(_el$65, "fg", RED);
523
+ _$setProp(_el$65, "wrapMode", "none");
524
+ _$insert(_el$65, marker, null);
525
+ return _el$65;
526
+ })()
527
+ }), null);
528
+ _$effect(_$p => _$setProp(_el$64, "fg", value().color, _$p));
529
+ return _el$60;
530
+ })()
531
+ }));
532
+ return _el$59;
533
+ })();
534
+ };
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Report
538
+ // ---------------------------------------------------------------------------
539
+
540
+ function report() {
541
+ const snap = snapshot();
542
+ const wallet = payingWallet(model(), snap);
543
+ const lines = [];
544
+ const label = {
545
+ go: "OpenCode — paying: GO (subscription)",
546
+ zen: "OpenCode — paying: ZEN (pay-per-use balance)",
547
+ ds: "DeepSeek — paying: balance (pay-per-use, no quota windows)",
548
+ or: "OpenRouter — paying: credit balance (pay-per-use, no quota windows)",
549
+ none: "No budget provider selected"
550
+ };
551
+ // Two silences that are not the same silence: no provider connected is a setup that has not
552
+ // happened, while "no budget provider selected" is a model whose provider the four wallets do
553
+ // not cover — a Go subscription and an Anthropic model, say. Answering the second for the
554
+ // first would name the wrong problem.
555
+ const nothing = !!snap && !connected(snap);
556
+ lines.push(nothing ? "No provider connected" : label[wallet]);
557
+ if (snap?.zen?.org) lines.push(`workspace: ${snap.zen.org}`);
558
+ lines.push("");
559
+ if (nothing) {
560
+ lines.push(" connect one with: opencode auth login");
561
+ return lines.join("\n");
562
+ }
563
+
564
+ // `pays` is true for the monthly window only: that is the window the paying wallet rule
565
+ // reads, so flagging an exhausted five-hour window would claim ZEN pays when it does not.
566
+ const goWindow = (name, window, pays) => {
567
+ if (!window || typeof window.percent !== "number") return ` ${name.padEnd(8)} (no data)`;
568
+ const percent = Math.round(window.percent);
569
+ const reset = countdown(window.resetsAt);
570
+ const flag = pays && window.status === "rate-limited" ? " ZEN paying" : "";
571
+ return ` ${name.padEnd(8)} ${bar(percent)} ${String(percent).padStart(3)}%${reset ? ` reset in ${reset}` : ""}${flag}`;
572
+ };
573
+ if (snap?.go && !snap.go.error) {
574
+ lines.push(goWindow("5h", snap.go.rolling, false));
575
+ lines.push(goWindow("weekly", snap.go.weekly, false));
576
+ lines.push(goWindow("monthly", snap.go.monthly, true));
577
+ } else if (snap?.go?.error) {
578
+ lines.push(` ${"go".padEnd(8)} ${snap.go.error}`);
579
+ }
580
+ if (snap?.ds && !snap.ds.error) {
581
+ lines.push(` ${"deepseek".padEnd(8)} ${usd(snap.ds.balance)}`);
582
+ } else if (snap?.ds?.error) {
583
+ lines.push(` ${"deepseek".padEnd(8)} ${snap.ds.error}`);
584
+ }
585
+ if (snap?.or && !snap.or.error) {
586
+ const of = typeof snap.or.totalCredits === "number" ? ` of ${usd(snap.or.totalCredits)}` : "";
587
+ const used = typeof snap.or.totalUsage === "number" ? ` (used ${usd(snap.or.totalUsage)})` : "";
588
+ lines.push(` ${"openrouter".padEnd(8)} ${usd(snap.or.balance)}${of}${used}`);
589
+ if (typeof snap.or.usageMonthly === "number") {
590
+ lines.push(` ${"".padEnd(8)} month ${usd(snap.or.usageMonthly)}`);
591
+ }
592
+ } else if (snap?.or?.error) {
593
+ lines.push(` ${"openrouter".padEnd(8)} ${snap.or.error}`);
594
+ }
595
+ if (snap?.zen && !snap.zen.error) {
596
+ // The console's figure, with the ledger beside it only when the account is under: the
597
+ // clamped credit would hide those cents, and they are spent money, not a rounding.
598
+ const debt = snap.zen.balance !== undefined && snap.zen.balance < 0 ? ` (debt ${usd(-snap.zen.balance)})` : "";
599
+ lines.push(` ${"zen".padEnd(8)} ${usd(zenCredit(snap.zen))}${debt}`);
600
+ } else if (snap?.zen?.error) {
601
+ lines.push(` ${"zen".padEnd(8)} ${snap.zen.error}`);
602
+ }
603
+
604
+ // The price regime only means something where the tariff moves.
605
+ if (wallet === "ds" || wallet === "go" && /^deepseek/.test(model()?.modelID ?? "")) {
606
+ const next = nextRegimeChange(clock());
607
+ lines.push("");
608
+ lines.push(` ${"peak".padEnd(8)} ${isDeepseekPeak(clock()) ? "PEAK — paying double" : "off-peak (half price)"}`);
609
+ lines.push(` ${"next".padEnd(8)} ${next.peak ? "peak" : "off-peak"} in ${countdown(new Date(next.at).toISOString())}`);
610
+ }
611
+ if (snap?.at) {
612
+ lines.push("");
613
+ lines.push(` updated ${new Date(snap.at).toLocaleTimeString()}`);
614
+ }
615
+ return lines.join("\n");
616
+ }
617
+
618
+ // ---------------------------------------------------------------------------
619
+ // Slots and command
620
+ // ---------------------------------------------------------------------------
621
+
622
+ const sidebarSlot = context.ui.slot({
623
+ prepend: "sidebar.content",
624
+ render: () => _$createComponent(Sidebar, {})
625
+ });
626
+ context.ui.slot({
627
+ append: "prompt.footer.status",
628
+ render: () => _$createComponent(Footer, {})
629
+ });
630
+ context.keymap.layer(() => ({
631
+ mode: "global",
632
+ commands: [{
633
+ id: "oc-budget.report",
634
+ title: "Budget report",
635
+ group: "Budget",
636
+ palette: true,
637
+ slash: {
638
+ name: "budget",
639
+ arguments: true
640
+ },
641
+ run: async input => {
642
+ await load((input ?? "").trim().toLowerCase() === "refresh");
643
+ await context.ui.dialog.alert({
644
+ title: `${BUDGET_GLYPH} Budget`,
645
+ message: report()
646
+ });
647
+ }
648
+ }]
649
+ }));
650
+ return () => {
651
+ clearInterval(timer);
652
+ unsubscribe?.();
653
+ sidebarSlot?.();
654
+ };
655
+ }
656
+ });