@rizal72/oc-budget 0.0.0-stage → 0.0.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/README.md +97 -2
- package/budget.ts +66 -0
- package/dist/gui-experimental.cjs +53 -0
- package/gui.tsx +622 -0
- package/header-menu.tsx +52 -0
- package/index.ts +366 -0
- package/package.json +33 -5
- package/peak.ts +104 -0
- package/rpc.ts +111 -0
- package/tui.tsx +504 -0
- package/vite.config.ts +6 -0
package/gui.tsx
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// oc-budget — GUI half.
|
|
2
|
+
//
|
|
3
|
+
// One surface: the figure in the session header, beside the app's own context ring and session cost, where
|
|
4
|
+
// a reader already looks for what the session is costing. Its popover carries the whole block — every
|
|
5
|
+
// wallet, the Go bar, the resets, the workspace, the peak mark — the same lines the terminal draws.
|
|
6
|
+
//
|
|
7
|
+
// The mark is a ring only where a percentage exists: Go's month. ZEN, DS and OR are money, and a ring
|
|
8
|
+
// around money would imitate a share of a cap that does not exist, so their figure carries the colour and
|
|
9
|
+
// the name stays quiet. The app's catalog has no money glyph — `providers` says provider, not money — so the
|
|
10
|
+
// card is drawn here; `ctx.ui.Icon` supplies the warning one.
|
|
11
|
+
//
|
|
12
|
+
// Colours come from the theme, never from hex: a GUI plugin has no theme API, but the app publishes its
|
|
13
|
+
// tokens as CSS variables, and these are the three alert levels the terminal uses.
|
|
14
|
+
import { Plugin, Slot, Style, createKeyed, type SlotMap } from "@opencode/plugin/gui"
|
|
15
|
+
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DS_WARN_USD,
|
|
19
|
+
GO_WARN_PERCENT,
|
|
20
|
+
OR_WARN_USD,
|
|
21
|
+
ZEN_WARN_USD,
|
|
22
|
+
ageText,
|
|
23
|
+
connected,
|
|
24
|
+
countdown,
|
|
25
|
+
payingWallet,
|
|
26
|
+
windowPercent,
|
|
27
|
+
} from "./budget.ts"
|
|
28
|
+
import type { Wallet } from "./budget.ts"
|
|
29
|
+
import { Budget } from "./rpc.ts"
|
|
30
|
+
import type { BudgetSnapshot } from "./rpc.ts"
|
|
31
|
+
import { HeaderMenu } from "./header-menu.tsx"
|
|
32
|
+
import { clock, doubles as peakDoubles, isPeak } from "./peak.ts"
|
|
33
|
+
|
|
34
|
+
/** The mark says "this tariff is in its doubled window"; the figure adds it to the *paying* model only. */
|
|
35
|
+
const PEAK_MARK = "↗"
|
|
36
|
+
|
|
37
|
+
const REFRESH_MS = 5 * 60 * 1000
|
|
38
|
+
/** A failed poll is not a slow poll: ask again in half a minute, before the five-minute tick. */
|
|
39
|
+
const RETRY_MS = 30_000
|
|
40
|
+
/** Two missed polls: from here the numbers on screen are history, and the figure says so. */
|
|
41
|
+
const STALE_MS = 2 * REFRESH_MS
|
|
42
|
+
const RPC_TIMEOUT_MS = 15_000
|
|
43
|
+
|
|
44
|
+
const CSS = `
|
|
45
|
+
/* The block that opens from the header figure wears that figure's own type: one size, 13px, weight 440, the
|
|
46
|
+
app's tight tracking. Hierarchy here is colour and weight, never a second size. */
|
|
47
|
+
/* anywhere: an error carrying a URL or a token must not paint outside the box. This is our content; the
|
|
48
|
+
overflow is ours too. */
|
|
49
|
+
.ocb { display: flex; flex-direction: column; gap: 5px; width: 100%; font-size: 13px; font-weight: 440;
|
|
50
|
+
letter-spacing: -0.04px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
|
51
|
+
.ocb-line { display: flex; align-items: center; gap: 6px; }
|
|
52
|
+
.ocb-name { font-weight: 600; letter-spacing: .02em; min-width: 2.6em; }
|
|
53
|
+
.ocb-figure { margin-left: auto; min-width: 4.2em; text-align: right; }
|
|
54
|
+
.ocb-peak { margin-left: 4px; }
|
|
55
|
+
.ocb-windows { gap: 4px; }
|
|
56
|
+
.ocb-ok { color: var(--v2-state-fg-success, inherit); }
|
|
57
|
+
.ocb-warn { color: var(--v2-state-fg-warning, inherit); }
|
|
58
|
+
.ocb-bad { color: var(--v2-state-fg-danger, inherit); }
|
|
59
|
+
.ocb-dim { color: var(--v2-text-text-muted, inherit); }
|
|
60
|
+
/* The empty state's two lines only: the words are short and the panel is 18em wide, so left-aligned they
|
|
61
|
+
leave a column of dead space under the figure they belong to. They sit against the same edge the figure
|
|
62
|
+
does, and nothing else in this plugin is right-aligned. */
|
|
63
|
+
.ocb-empty { text-align: right; }
|
|
64
|
+
.ocb-accent { color: var(--v2-text-text-accent, inherit); }
|
|
65
|
+
.ocb-bar { flex: 1 1 auto; min-width: 90px; height: 6px; border-radius: 3px; overflow: hidden;
|
|
66
|
+
background: color-mix(in srgb, currentColor 16%, transparent); }
|
|
67
|
+
.ocb-fill { display: block; height: 100%; border-radius: 3px; background: currentColor; }
|
|
68
|
+
.ocb-rule { height: 1px; margin: 2px 0; background: color-mix(in srgb, currentColor 18%, transparent); }
|
|
69
|
+
/* The app's own toolbar button, hand-written: its height and its hover plate are what make the figure read as
|
|
70
|
+
one of the header's controls. */
|
|
71
|
+
.ocb-head { display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 6px; border: 0;
|
|
72
|
+
border-radius: 6px; background: none; color: var(--v2-text-text-faint); font: inherit; font-size: 13px;
|
|
73
|
+
font-weight: 440; letter-spacing: -0.04px; font-variant-numeric: tabular-nums; cursor: pointer; }
|
|
74
|
+
.ocb-head:hover, .ocb-head:focus-visible {
|
|
75
|
+
background: color-mix(in srgb, var(--v2-text-text-base) 8%, transparent);
|
|
76
|
+
color: var(--v2-text-text-base);
|
|
77
|
+
}
|
|
78
|
+
/* Two classes, so the open plate outranks the hover plate while the pointer still rests on the trigger. */
|
|
79
|
+
.ocb-head.ocb-head-on {
|
|
80
|
+
background: color-mix(in srgb, var(--v2-text-text-base) 12%, transparent);
|
|
81
|
+
color: var(--v2-text-text-base);
|
|
82
|
+
}
|
|
83
|
+
.ocb-mark { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; }
|
|
84
|
+
.ocb-head-label { white-space: nowrap; color: inherit; }
|
|
85
|
+
/* The block: Kobalte puts it in the app's own top layer and positions it from the trigger, so there is no
|
|
86
|
+
position rule here — only the box. */
|
|
87
|
+
.ocb-pop { width: max-content; min-width: 18em;
|
|
88
|
+
max-width: min(22em, calc(100vw - 16px)); padding: 8px 10px; border-radius: 8px;
|
|
89
|
+
border: 1px solid var(--v2-border-border-muted, rgba(255, 255, 255, .12));
|
|
90
|
+
background: var(--v2-background-bg-layer-02, #1b1b1b);
|
|
91
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, .35); color: var(--v2-text-text-base, inherit); font-size: 13px; }
|
|
92
|
+
/* The hover hint is wider than this panel, so it would show beside it: while any figure's panel is open, no
|
|
93
|
+
tooltip is drawn anywhere. CSS cannot select only ours, and the panel is not modal, so the rest of the header
|
|
94
|
+
stays hoverable — a trade, recorded in docs/oc-budget.md. */
|
|
95
|
+
body:has(.ocb-head[aria-expanded="true"]) [data-component="tooltip-v2"] { display: none; }
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
type Tone = "success" | "warning" | "danger"
|
|
99
|
+
|
|
100
|
+
const CLASS: Record<Tone, string> = { success: "ocb-ok", warning: "ocb-warn", danger: "ocb-bad" }
|
|
101
|
+
const cls = (tone: Tone | undefined) => (tone ? CLASS[tone] : "")
|
|
102
|
+
|
|
103
|
+
/** A stalled call must not leave the figure frozen: the guard resets only when the promise settles. */
|
|
104
|
+
const withTimeout = <T,>(promise: Promise<T>, ms: number): Promise<T> =>
|
|
105
|
+
new Promise<T>((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => reject(new Error("rpc timeout")), ms)
|
|
107
|
+
promise.then(
|
|
108
|
+
(value) => {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
resolve(value)
|
|
111
|
+
},
|
|
112
|
+
(error) => {
|
|
113
|
+
clearTimeout(timer)
|
|
114
|
+
reject(error)
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const usd = (value: number | undefined) =>
|
|
120
|
+
typeof value === "number" && Number.isFinite(value) ? `$${value.toFixed(2)}` : "—"
|
|
121
|
+
|
|
122
|
+
const percent = (value: number | null) => (value === null ? "—" : `${Math.round(value)}%`)
|
|
123
|
+
|
|
124
|
+
/** The bar's width: a percent clamped to its track, because the value can overshoot and never undershoot. */
|
|
125
|
+
const fill = (value: number | null) => Math.max(0, Math.min(100, value ?? 0))
|
|
126
|
+
|
|
127
|
+
/** Red at the wall, yellow past the threshold, green elsewhere — the answer is the same for a percent
|
|
128
|
+
* and for a balance, only the wall differs. */
|
|
129
|
+
const tone = (value: number | null, warn: number, empty: number): Tone | undefined => {
|
|
130
|
+
if (value === null) return undefined
|
|
131
|
+
if (value <= empty) return "danger"
|
|
132
|
+
return value <= warn ? "warning" : "success"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A quota window's colour, as the terminal draws it: done at 100, alert at the threshold. */
|
|
136
|
+
const windowTone = (value: number | null): Tone | undefined => {
|
|
137
|
+
if (value === null) return undefined
|
|
138
|
+
if (value >= 100) return "danger"
|
|
139
|
+
return value >= GO_WARN_PERCENT ? "warning" : "success"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type Line = { figure?: number; tone?: Tone; note?: string }
|
|
143
|
+
|
|
144
|
+
/** What the header row and the popover both read: a session and its screen, and nothing else. */
|
|
145
|
+
type SurfaceInput = Pick<SlotMap["session.header"], "session" | "screen">
|
|
146
|
+
|
|
147
|
+
/** Everything the figure and its block say, computed once. Reads the signals, so call it from JSX: the slot
|
|
148
|
+
* renders it reactively, and the popover reads the same signal while it is open. */
|
|
149
|
+
type Figures = {
|
|
150
|
+
ready: boolean
|
|
151
|
+
/** No wallet exists at all: an account with no provider connected, which is a state and not a fault.
|
|
152
|
+
* Every surface says it instead of drawing a figure, and the header keeps its figure clickable. */
|
|
153
|
+
empty: boolean
|
|
154
|
+
error?: string
|
|
155
|
+
label?: string
|
|
156
|
+
walletTone?: Tone
|
|
157
|
+
/** Which wallet pays: the ring is drawn where a share of a cap exists, the figure where money does. */
|
|
158
|
+
wallet: Wallet
|
|
159
|
+
stale: boolean
|
|
160
|
+
age: number
|
|
161
|
+
atPeak: boolean
|
|
162
|
+
marked: boolean
|
|
163
|
+
go?: {
|
|
164
|
+
rolling: number | null
|
|
165
|
+
weekly: number | null
|
|
166
|
+
monthly: number | null
|
|
167
|
+
resets: { rolling: string; weekly: string; monthly: string }
|
|
168
|
+
error?: string
|
|
169
|
+
}
|
|
170
|
+
workspace?: string
|
|
171
|
+
/** A wallet the snapshot does not carry has no connection: it is left out, not drawn empty. */
|
|
172
|
+
ds?: Line
|
|
173
|
+
or?: Line
|
|
174
|
+
zen?: Line
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const NO_LINES: Pick<Figures, "ds" | "or" | "zen"> = {}
|
|
178
|
+
|
|
179
|
+
export default Plugin.define({
|
|
180
|
+
setup(ctx) {
|
|
181
|
+
const [snapshot, setSnapshot] = createSignal<{ server: string; value: BudgetSnapshot } | undefined>(
|
|
182
|
+
undefined,
|
|
183
|
+
)
|
|
184
|
+
/** Why the last read failed, and nothing else: cleared by the next good one. */
|
|
185
|
+
const [failure, setFailure] = createSignal<string | undefined>(undefined)
|
|
186
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
187
|
+
let retry: ReturnType<typeof setTimeout> | undefined
|
|
188
|
+
let reading: string | undefined
|
|
189
|
+
/** The current server's read, for the popover: opening it must not show a figure from five minutes ago. */
|
|
190
|
+
let refreshNow: (() => void) | undefined
|
|
191
|
+
|
|
192
|
+
// One poll per server, on that server's own client: the credential lives where the plugin runs, and a
|
|
193
|
+
// server that does not run it has nothing to answer.
|
|
194
|
+
createEffect(() => {
|
|
195
|
+
const session = ctx.sessions.current()
|
|
196
|
+
const server = session?.server.id
|
|
197
|
+
|
|
198
|
+
if (timer) clearInterval(timer)
|
|
199
|
+
if (retry) clearTimeout(retry)
|
|
200
|
+
timer = undefined
|
|
201
|
+
retry = undefined
|
|
202
|
+
refreshNow = undefined
|
|
203
|
+
// Another server's failure is not this session's: the effect re-runs when the routing moves.
|
|
204
|
+
setFailure(undefined)
|
|
205
|
+
if (!server) {
|
|
206
|
+
setSnapshot(undefined)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const refresh = (force = false) => {
|
|
211
|
+
if (reading === server) return
|
|
212
|
+
reading = server
|
|
213
|
+
const input = force ? { force: true } : {}
|
|
214
|
+
void withTimeout(session!.server.client.rpc(Budget).snapshot(input), RPC_TIMEOUT_MS)
|
|
215
|
+
.then((value) => {
|
|
216
|
+
// A slow answer from the server that was polled before must not overwrite this one.
|
|
217
|
+
if (ctx.sessions.current()?.server.id !== server) return
|
|
218
|
+
setSnapshot({ server, value: value as BudgetSnapshot })
|
|
219
|
+
setFailure(undefined)
|
|
220
|
+
})
|
|
221
|
+
.catch((error: unknown) => {
|
|
222
|
+
// Keep the last good reading: a failed poll says nothing about the balance, and
|
|
223
|
+
// blanking the figure for five minutes cost a whole night (measured 2026-10-28, the
|
|
224
|
+
// pill gone from 07:00 to 12:26, every poll a fast 500). Ask again sooner instead.
|
|
225
|
+
if (ctx.sessions.current()?.server.id !== server) return
|
|
226
|
+
setFailure(error instanceof Error ? error.message : String(error))
|
|
227
|
+
if (retry) clearTimeout(retry)
|
|
228
|
+
retry = setTimeout(refresh, RETRY_MS)
|
|
229
|
+
})
|
|
230
|
+
.finally(() => {
|
|
231
|
+
if (reading === server) reading = undefined
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
refreshNow = () => refresh(true)
|
|
236
|
+
refresh()
|
|
237
|
+
timer = setInterval(refresh, REFRESH_MS)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
onCleanup(() => {
|
|
241
|
+
if (timer) clearInterval(timer)
|
|
242
|
+
if (retry) clearTimeout(retry)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const view = (input: SurfaceInput): Figures => {
|
|
246
|
+
const current = snapshot()
|
|
247
|
+
const error = failure()
|
|
248
|
+
const model = input.screen.composer.model()
|
|
249
|
+
const now = clock()
|
|
250
|
+
// Two questions, two functions (`peak.ts`). `isPeak` is the tariff: GO and DS are marked whoever
|
|
251
|
+
// pays, because the mark is the reminder not to switch *to* them. `doubles` is your own spend.
|
|
252
|
+
const atPeak = isPeak(now)
|
|
253
|
+
|
|
254
|
+
if (!current || current.server !== input.session.server.id) {
|
|
255
|
+
return {
|
|
256
|
+
ready: false,
|
|
257
|
+
empty: false,
|
|
258
|
+
error,
|
|
259
|
+
wallet: "none",
|
|
260
|
+
stale: false,
|
|
261
|
+
age: 0,
|
|
262
|
+
atPeak,
|
|
263
|
+
marked: false,
|
|
264
|
+
...NO_LINES,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const value = current.value
|
|
269
|
+
const wallet = payingWallet(model, value)
|
|
270
|
+
const go = value.go
|
|
271
|
+
const rolling = windowPercent(go?.rolling)
|
|
272
|
+
const weekly = windowPercent(go?.weekly)
|
|
273
|
+
const monthly = windowPercent(go?.monthly)
|
|
274
|
+
const zen = value.zen?.available ?? value.zen?.balance
|
|
275
|
+
const age = Date.now() - value.at
|
|
276
|
+
|
|
277
|
+
// A wallet absent from the snapshot has no connection, so the figure says nothing — a dash
|
|
278
|
+
// would still claim a reading. The popover leaves its line out for the same reason.
|
|
279
|
+
const label =
|
|
280
|
+
wallet === "go"
|
|
281
|
+
? go
|
|
282
|
+
? `GO · ${percent(monthly)}`
|
|
283
|
+
: undefined
|
|
284
|
+
: wallet === "zen"
|
|
285
|
+
? value.zen
|
|
286
|
+
? `ZEN · ${usd(zen)}`
|
|
287
|
+
: undefined
|
|
288
|
+
: wallet === "ds"
|
|
289
|
+
? value.ds
|
|
290
|
+
? `DS · ${usd(value.ds.balance)}`
|
|
291
|
+
: undefined
|
|
292
|
+
: wallet === "or"
|
|
293
|
+
? value.or
|
|
294
|
+
? `OR · ${usd(value.or.balance)}`
|
|
295
|
+
: undefined
|
|
296
|
+
: undefined
|
|
297
|
+
|
|
298
|
+
// No figure at all is not "healthy": the colour is left out, as it is for a money wallet with
|
|
299
|
+
// nothing to read.
|
|
300
|
+
const goTone: Tone | undefined = [rolling, weekly, monthly].every((figure) => figure === null)
|
|
301
|
+
? undefined
|
|
302
|
+
: [rolling, weekly, monthly].some((figure) => figure !== null && figure >= 100)
|
|
303
|
+
? "danger"
|
|
304
|
+
: [rolling, weekly, monthly].some(
|
|
305
|
+
(figure) => figure !== null && figure >= GO_WARN_PERCENT,
|
|
306
|
+
)
|
|
307
|
+
? "warning"
|
|
308
|
+
: "success"
|
|
309
|
+
const walletTone: Tone | undefined =
|
|
310
|
+
wallet === "go"
|
|
311
|
+
? goTone
|
|
312
|
+
: wallet === "zen"
|
|
313
|
+
? tone(zen ?? null, ZEN_WARN_USD, 0)
|
|
314
|
+
: wallet === "ds"
|
|
315
|
+
? tone(value.ds?.balance ?? null, DS_WARN_USD, 0)
|
|
316
|
+
: tone(value.or?.balance ?? null, OR_WARN_USD, 0)
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
ready: true,
|
|
320
|
+
empty: !connected(value),
|
|
321
|
+
error,
|
|
322
|
+
label,
|
|
323
|
+
walletTone,
|
|
324
|
+
wallet,
|
|
325
|
+
stale: age > STALE_MS,
|
|
326
|
+
age,
|
|
327
|
+
atPeak,
|
|
328
|
+
marked: peakDoubles(model, wallet, now),
|
|
329
|
+
go: go
|
|
330
|
+
? {
|
|
331
|
+
rolling,
|
|
332
|
+
weekly,
|
|
333
|
+
monthly,
|
|
334
|
+
// One reset per window, as the block prints them: the monthly one alone said nothing
|
|
335
|
+
// about the two shorter windows that empty first.
|
|
336
|
+
resets: {
|
|
337
|
+
rolling: countdown(go.rolling?.resetsAt),
|
|
338
|
+
weekly: countdown(go.weekly?.resetsAt),
|
|
339
|
+
monthly: countdown(go.monthly?.resetsAt),
|
|
340
|
+
},
|
|
341
|
+
error: go.error,
|
|
342
|
+
}
|
|
343
|
+
: undefined,
|
|
344
|
+
// The block says the workspace only when the account has peers: alone in it, the name says nothing.
|
|
345
|
+
workspace: value.zen?.org && (value.zen.peers ?? 0) > 1 ? value.zen.org : undefined,
|
|
346
|
+
ds: value.ds && {
|
|
347
|
+
figure: value.ds.balance,
|
|
348
|
+
tone: tone(value.ds.balance ?? null, DS_WARN_USD, 0),
|
|
349
|
+
note: value.ds.error,
|
|
350
|
+
},
|
|
351
|
+
or: value.or && {
|
|
352
|
+
figure: value.or.balance,
|
|
353
|
+
tone: tone(value.or.balance ?? null, OR_WARN_USD, 0),
|
|
354
|
+
note: value.or.error,
|
|
355
|
+
},
|
|
356
|
+
zen: value.zen && { figure: zen, tone: tone(zen ?? null, ZEN_WARN_USD, 0), note: value.zen.error },
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** One window: its percent on its own colour, its reset on the right, as the block prints them. */
|
|
361
|
+
const WindowRow = (props: { label: string; percent: number | null; reset: string }) => (
|
|
362
|
+
<div class="ocb-line ocb-windows">
|
|
363
|
+
<span class="ocb-dim">{props.label}</span>
|
|
364
|
+
<span class={cls(windowTone(props.percent))}>{percent(props.percent)}</span>
|
|
365
|
+
<Show when={props.reset}>
|
|
366
|
+
<span class="ocb-dim ocb-figure">reset {props.reset}</span>
|
|
367
|
+
</Show>
|
|
368
|
+
</div>
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
const WalletLine = (props: { name: string; line: Line; mark?: string }) => (
|
|
372
|
+
<>
|
|
373
|
+
<div class="ocb-line">
|
|
374
|
+
<span class={`ocb-name ${cls(props.line.tone)}`}>{props.name}</span>
|
|
375
|
+
<span class={`ocb-figure ${cls(props.line.tone)}`}>{usd(props.line.figure)}</span>
|
|
376
|
+
<Show when={props.mark}>
|
|
377
|
+
<span class="ocb-bad ocb-mark">{props.mark}</span>
|
|
378
|
+
</Show>
|
|
379
|
+
</div>
|
|
380
|
+
<Show when={props.line.note}>
|
|
381
|
+
<div class="ocb-dim">{props.line.note}</div>
|
|
382
|
+
</Show>
|
|
383
|
+
</>
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
/** The block, wherever it is shown: the popover under the header figure. The same lines, the same
|
|
387
|
+
* colours, the same order the terminal draws. */
|
|
388
|
+
const Block = (props: { input: SurfaceInput }) => {
|
|
389
|
+
const figures = createMemo(() => view(props.input))
|
|
390
|
+
// The tick is five minutes, so open on a fresh read: one call, and the figure follows it, because
|
|
391
|
+
// both surfaces read this one signal.
|
|
392
|
+
onMount(() => {
|
|
393
|
+
refreshNow?.()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<div class="ocb">
|
|
398
|
+
<Show
|
|
399
|
+
when={figures().ready ? figures() : undefined}
|
|
400
|
+
fallback={<div class="ocb-dim">{figures().error ?? "reading…"}</div>}
|
|
401
|
+
>
|
|
402
|
+
{(f: () => Figures) => (
|
|
403
|
+
<>
|
|
404
|
+
<Show when={f().stale}>
|
|
405
|
+
<div class="ocb-warn">stale {ageText(f().age)}</div>
|
|
406
|
+
</Show>
|
|
407
|
+
<Show when={f().error}>
|
|
408
|
+
<div class="ocb-dim">{f().error}</div>
|
|
409
|
+
</Show>
|
|
410
|
+
<Show when={f().empty}>
|
|
411
|
+
<div class="ocb-dim ocb-empty">no provider connected</div>
|
|
412
|
+
<div class="ocb-dim ocb-empty">opencode auth login</div>
|
|
413
|
+
</Show>
|
|
414
|
+
<Show when={f().go}>
|
|
415
|
+
{(g: () => NonNullable<Figures["go"]>) => (
|
|
416
|
+
<>
|
|
417
|
+
<div class="ocb-line">
|
|
418
|
+
<span class={`ocb-name ${cls(windowTone(g().monthly))}`}>GO</span>
|
|
419
|
+
<span
|
|
420
|
+
class={`ocb-bar ${cls(windowTone(g().monthly))}`}
|
|
421
|
+
role="progressbar"
|
|
422
|
+
aria-valuenow={fill(g().monthly)}
|
|
423
|
+
aria-valuemin={0}
|
|
424
|
+
aria-valuemax={100}
|
|
425
|
+
>
|
|
426
|
+
<span
|
|
427
|
+
class="ocb-fill"
|
|
428
|
+
style={{ width: `${fill(g().monthly)}%` }}
|
|
429
|
+
/>
|
|
430
|
+
</span>
|
|
431
|
+
<span class={`ocb-figure ${cls(windowTone(g().monthly))}`}>
|
|
432
|
+
{percent(g().monthly)}
|
|
433
|
+
</span>
|
|
434
|
+
<Show when={f().atPeak}>
|
|
435
|
+
<span class="ocb-bad ocb-peak">{PEAK_MARK}</span>
|
|
436
|
+
</Show>
|
|
437
|
+
</div>
|
|
438
|
+
<Show
|
|
439
|
+
when={!g().error}
|
|
440
|
+
fallback={<div class="ocb-dim">{g().error}</div>}
|
|
441
|
+
>
|
|
442
|
+
<WindowRow label="5h" percent={g().rolling} reset={g().resets.rolling} />
|
|
443
|
+
<WindowRow label="wk" percent={g().weekly} reset={g().resets.weekly} />
|
|
444
|
+
<WindowRow label="mo" percent={g().monthly} reset={g().resets.monthly} />
|
|
445
|
+
</Show>
|
|
446
|
+
</>
|
|
447
|
+
)}
|
|
448
|
+
</Show>
|
|
449
|
+
<Show when={f().workspace}>
|
|
450
|
+
<div class="ocb-line">
|
|
451
|
+
<span class="ocb-dim">workspace</span>
|
|
452
|
+
<span class="ocb-accent ocb-figure">{f().workspace}</span>
|
|
453
|
+
</div>
|
|
454
|
+
</Show>
|
|
455
|
+
<div class="ocb-rule" />
|
|
456
|
+
<Show when={f().ds}>
|
|
457
|
+
{(line: () => Line) => (
|
|
458
|
+
<WalletLine name="DS" line={line()} mark={f().atPeak ? PEAK_MARK : ""} />
|
|
459
|
+
)}
|
|
460
|
+
</Show>
|
|
461
|
+
<Show when={f().or}>
|
|
462
|
+
{(line: () => Line) => <WalletLine name="OR" line={line()} />}
|
|
463
|
+
</Show>
|
|
464
|
+
<Show when={f().zen}>
|
|
465
|
+
{(line: () => Line) => <WalletLine name="ZEN" line={line()} />}
|
|
466
|
+
</Show>
|
|
467
|
+
</>
|
|
468
|
+
)}
|
|
469
|
+
</Show>
|
|
470
|
+
</div>
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The month's consumption as a ring, at the app's own geometry: an indicator circle is 16 px wide with a
|
|
475
|
+
* 2 px stroke and a radius of `8 - stroke/2`, so the two rings are the same circle and the same family. */
|
|
476
|
+
const RING_STROKE = 2
|
|
477
|
+
const RING_R = 8 - RING_STROKE / 2
|
|
478
|
+
const RING_C = 2 * Math.PI * RING_R
|
|
479
|
+
const Ring = (props: { percent: number | null }) => (
|
|
480
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
481
|
+
<circle
|
|
482
|
+
cx="8"
|
|
483
|
+
cy="8"
|
|
484
|
+
r={RING_R}
|
|
485
|
+
stroke="currentColor"
|
|
486
|
+
stroke-opacity="0.18"
|
|
487
|
+
stroke-width={RING_STROKE}
|
|
488
|
+
/>
|
|
489
|
+
<circle
|
|
490
|
+
cx="8"
|
|
491
|
+
cy="8"
|
|
492
|
+
r={RING_R}
|
|
493
|
+
stroke="currentColor"
|
|
494
|
+
stroke-width={RING_STROKE}
|
|
495
|
+
stroke-dasharray={RING_C}
|
|
496
|
+
stroke-dashoffset={RING_C * (1 - fill(props.percent) / 100)}
|
|
497
|
+
transform="rotate(-90 8 8)"
|
|
498
|
+
/>
|
|
499
|
+
</svg>
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
/** The mark for a wallet that is money: a card, at the ring's size so the row keeps one rhythm. It is not a
|
|
503
|
+
* share of anything, and a ring around money would read as a percentage that does not exist. */
|
|
504
|
+
const WalletMark = () => (
|
|
505
|
+
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden="true">
|
|
506
|
+
<rect x="2" y="4.5" width="12" height="8" rx="2" stroke="currentColor" stroke-width="1.6" />
|
|
507
|
+
<path d="M2 7h12" stroke="currentColor" stroke-width="1.6" />
|
|
508
|
+
<circle cx="11" cy="9.6" r="1.1" fill="currentColor" />
|
|
509
|
+
</svg>
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
/** Which figure draws, and what it says. */
|
|
513
|
+
type Mark = "ring" | "card" | "error"
|
|
514
|
+
|
|
515
|
+
/** The figure in the session header, beside the app's own context ring and cost. The mark carries the
|
|
516
|
+
* colour: a ring where a percentage exists, a card where the wallet is money, the warning icon when
|
|
517
|
+
* nothing could be read. The popover is the platform's — the top layer, the light dismiss and Escape
|
|
518
|
+
* come with it. */
|
|
519
|
+
const Tooltip = ctx.ui.Tooltip
|
|
520
|
+
const HeaderBudget = (props: { input: SlotMap["session.header"] }) => {
|
|
521
|
+
const figures = () => view(props.input)
|
|
522
|
+
const [open, setOpen] = createSignal(false)
|
|
523
|
+
|
|
524
|
+
/** The popover owns its dismissals — outside click, Escape — so this is the only place an open can
|
|
525
|
+
* start, and it starts on a fresh read: the tick is five minutes. */
|
|
526
|
+
const change = (next: boolean) => {
|
|
527
|
+
if (next) refreshNow?.()
|
|
528
|
+
setOpen(next)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Nothing read yet is not an error; a failure with nothing behind it is one, and hiding it is what
|
|
532
|
+
* cost a whole night — `docs/gui-plugins.md`, *a pill never shows a stale value as fresh*. Stale wins
|
|
533
|
+
* over the figure: what is on screen is history, and the mark says so. */
|
|
534
|
+
const shown = () => {
|
|
535
|
+
const f = figures()
|
|
536
|
+
if (f.ready && f.label) {
|
|
537
|
+
const mark: Mark = f.wallet === "go" && f.go ? "ring" : "card"
|
|
538
|
+
const tone = f.stale ? "warning" : f.walletTone
|
|
539
|
+
return { f, text: f.label, tone: tone as Tone | undefined, mark }
|
|
540
|
+
}
|
|
541
|
+
if (f.error) {
|
|
542
|
+
return { f, text: "budget", tone: "danger" as Tone, mark: "error" as Mark }
|
|
543
|
+
}
|
|
544
|
+
// Nothing connected, and the figure still exists: the plugin is installed and running, so
|
|
545
|
+
// vanishing would say it is not. The mark is the wallet's own, the tone absent, so it reads
|
|
546
|
+
// as switched off rather than as an alarm.
|
|
547
|
+
if (f.ready && f.empty) {
|
|
548
|
+
return { f, text: "budget", tone: undefined as Tone | undefined, mark: "card" as Mark }
|
|
549
|
+
}
|
|
550
|
+
return undefined
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const seat = (f: Figures) =>
|
|
554
|
+
f.error ??
|
|
555
|
+
(f.empty
|
|
556
|
+
? "Budget · no provider connected"
|
|
557
|
+
: f.stale
|
|
558
|
+
? `Budget · last read ${ageText(f.age)} ago`
|
|
559
|
+
: "Budget — what pays, and every wallet")
|
|
560
|
+
|
|
561
|
+
// `shown()` can go falsy and come back while the panel is open — the server switches and returns, a
|
|
562
|
+
// snapshot loses the paying wallet's label — and the menu would remount already open, with no gesture
|
|
563
|
+
// behind it. Closing with the menu that vanishes is the only state that cannot lie.
|
|
564
|
+
createKeyed(
|
|
565
|
+
() => (shown() ? true : undefined),
|
|
566
|
+
() => {},
|
|
567
|
+
{ otherwise: () => setOpen(false) },
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
return (
|
|
571
|
+
<Show when={shown()}>
|
|
572
|
+
{(s: () => NonNullable<ReturnType<typeof shown>>) => (
|
|
573
|
+
<HeaderMenu
|
|
574
|
+
label={s().f.stale ? `~${s().text}` : s().text}
|
|
575
|
+
hint={seat(s().f)}
|
|
576
|
+
tip={Tooltip}
|
|
577
|
+
open={open()}
|
|
578
|
+
onOpenChange={change}
|
|
579
|
+
trigger={
|
|
580
|
+
<>
|
|
581
|
+
<span class={`ocb-mark ${cls(s().tone)}`}>
|
|
582
|
+
<Show
|
|
583
|
+
when={s().mark === "ring"}
|
|
584
|
+
fallback={
|
|
585
|
+
s().mark === "error" ? (
|
|
586
|
+
<ctx.ui.Icon name="circle-exclamation" size="small" />
|
|
587
|
+
) : (
|
|
588
|
+
<WalletMark />
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
>
|
|
592
|
+
<Show when={s().f.go}>
|
|
593
|
+
{(g: () => NonNullable<Figures["go"]>) => <Ring percent={g().monthly} />}
|
|
594
|
+
</Show>
|
|
595
|
+
</Show>
|
|
596
|
+
</span>
|
|
597
|
+
<Show when={!props.input.compact}>
|
|
598
|
+
<span class="ocb-head-label">{s().f.stale ? `~${s().text}` : s().text}</span>
|
|
599
|
+
</Show>
|
|
600
|
+
<Show when={s().f.marked}>
|
|
601
|
+
<span class="ocb-head-label ocb-bad" aria-hidden="true">
|
|
602
|
+
{PEAK_MARK}
|
|
603
|
+
</span>
|
|
604
|
+
</Show>
|
|
605
|
+
</>
|
|
606
|
+
}
|
|
607
|
+
>
|
|
608
|
+
<Block input={props.input} />
|
|
609
|
+
</HeaderMenu>
|
|
610
|
+
)}
|
|
611
|
+
</Show>
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
ctx.add(Style, CSS)
|
|
616
|
+
|
|
617
|
+
// One surface: the figure in the session header, where the app already draws its context ring and the
|
|
618
|
+
// session's cost — `docs/gui-plugins.md`, *Where a contribution lands, by layout*. Order 11 puts it after
|
|
619
|
+
// the app's figures (10) and before its actions (20), and the tie at 10 would have decided it by luck.
|
|
620
|
+
ctx.add(Slot, { at: "session.header", order: 11, render: (input) => <HeaderBudget input={input} /> })
|
|
621
|
+
},
|
|
622
|
+
})
|
package/header-menu.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// oc-budget/header-menu.tsx — the popover the header figure opens, and the only place this plugin decides how
|
|
2
|
+
// a dropdown is built. The pattern is the app's own (`packages/gui-plugins/src/details/popover.tsx`): the open
|
|
3
|
+
// state is ours, so a second click on the trigger closes what the first opened. The platform's
|
|
4
|
+
// `popover="auto"` cannot do that — it dismisses on any click outside the panel, and the trigger is outside,
|
|
5
|
+
// so the click that should close arrives at a button whose panel is already gone and reopens it.
|
|
6
|
+
//
|
|
7
|
+
// It sits inside this plugin, with one consumer: the second slot that needs a dropdown lifts it out as it is.
|
|
8
|
+
// The library is Kobalte, pinned to the version the app itself ships — `docs/oc-budget.md`, *The header
|
|
9
|
+
// popover*.
|
|
10
|
+
import { Popover } from "@kobalte/core/popover"
|
|
11
|
+
|
|
12
|
+
export function HeaderMenu(props: {
|
|
13
|
+
/** The accessible name, on the trigger and on the panel. */
|
|
14
|
+
readonly label: string
|
|
15
|
+
/** The tooltip while the trigger is hovered; the host's component, so the shell stays free of the plugin. */
|
|
16
|
+
readonly hint: string
|
|
17
|
+
/** The host's Tooltip component, passed in so the shell stays free of the plugin context. */
|
|
18
|
+
readonly tip: any
|
|
19
|
+
readonly open: boolean
|
|
20
|
+
readonly onOpenChange: (open: boolean) => void
|
|
21
|
+
/** What the trigger draws; the button around it is the popover's, so press and focus are the host's. */
|
|
22
|
+
readonly trigger: JSX.Element
|
|
23
|
+
readonly children: JSX.Element
|
|
24
|
+
}) {
|
|
25
|
+
const Tip = props.tip
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Popover
|
|
29
|
+
open={props.open}
|
|
30
|
+
onOpenChange={props.onOpenChange}
|
|
31
|
+
placement="bottom-end"
|
|
32
|
+
gutter={6}
|
|
33
|
+
overflowPadding={12}
|
|
34
|
+
>
|
|
35
|
+
<Tip value={props.hint} placement="bottom">
|
|
36
|
+
<Popover.Trigger
|
|
37
|
+
class={`ocb-head ${props.open ? "ocb-head-on" : ""}`}
|
|
38
|
+
aria-label={props.label}
|
|
39
|
+
aria-haspopup="dialog"
|
|
40
|
+
aria-expanded={props.open}
|
|
41
|
+
>
|
|
42
|
+
{props.trigger}
|
|
43
|
+
</Popover.Trigger>
|
|
44
|
+
</Tip>
|
|
45
|
+
<Popover.Portal>
|
|
46
|
+
<Popover.Content class="ocb-pop" role="dialog" aria-label={props.label}>
|
|
47
|
+
{props.children}
|
|
48
|
+
</Popover.Content>
|
|
49
|
+
</Popover.Portal>
|
|
50
|
+
</Popover>
|
|
51
|
+
)
|
|
52
|
+
}
|