@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 +5 -5
- package/tui.js +656 -0
- package/gui.tsx +0 -622
- package/header-menu.tsx +0 -52
- package/tui.tsx +0 -504
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
|
-
})
|