@rizal72/oc-budget 0.0.1 → 0.1.1

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