@xtruder/opencode-claude-max-plugin 0.1.17 → 0.2.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/src/tui.tsx ADDED
@@ -0,0 +1,426 @@
1
+ // @ts-nocheck
2
+ /** @jsxImportSource @opentui/solid */
3
+ import type {
4
+ TuiPlugin,
5
+ TuiPluginApi,
6
+ TuiPluginModule,
7
+ TuiThemeCurrent,
8
+ } from "@opencode-ai/plugin/tui"
9
+ import { createMemo, createSignal, Show } from "solid-js"
10
+ import {
11
+ fetchUsage,
12
+ formatReset,
13
+ readUsageCache,
14
+ writeUsageCache,
15
+ bestUsageFromCache,
16
+ shouldFetchApi,
17
+ type UsageData,
18
+ } from "./usage.ts"
19
+
20
+ const id = "anthropic-sdk-usage"
21
+
22
+ type Api = TuiPluginApi
23
+
24
+ // ─── Config ──────────────────────────────────────────────────────────
25
+
26
+ type Cfg = {
27
+ enabled: boolean
28
+ sidebar: boolean
29
+ poll_interval: number
30
+ }
31
+
32
+ const bool = (value: unknown, fallback: boolean) => {
33
+ if (typeof value !== "boolean") return fallback
34
+ return value
35
+ }
36
+
37
+ const num = (value: unknown, fallback: number) => {
38
+ if (typeof value !== "number") return fallback
39
+ return value
40
+ }
41
+
42
+ const rec = (value: unknown) => {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return
44
+ return Object.fromEntries(Object.entries(value))
45
+ }
46
+
47
+ const cfg = (opts: Record<string, unknown> | undefined): Cfg => ({
48
+ enabled: bool(opts?.enabled, true),
49
+ sidebar: bool(opts?.sidebar, true),
50
+ poll_interval: Math.max(10, num(opts?.poll_interval, 60)),
51
+ })
52
+
53
+ // ─── Usage store ─────────────────────────────────────────────────────
54
+
55
+ type UsageStatus = "idle" | "loading" | "ready" | "no-credentials" | "error"
56
+
57
+ function createUsageStore(pollInterval: number) {
58
+ const [usage, setUsage] = createSignal<UsageData | null>(null)
59
+ const [status, setStatus] = createSignal<UsageStatus>("idle")
60
+
61
+ /**
62
+ * Refresh usage data. Priority:
63
+ * 1. Try the usage API (richer data, per-model breakdown) — unless rate limited
64
+ * 2. Fall back to cache file (updated by server from response headers)
65
+ */
66
+ const refresh = async () => {
67
+ const cache = readUsageCache()
68
+
69
+ // 1. Try the API if not rate limited and TTL expired
70
+ if (shouldFetchApi(cache)) {
71
+ setStatus(usage() ? "ready" : "loading")
72
+ try {
73
+ const { data, retryAfterMs } = await fetchUsage()
74
+ if (data) {
75
+ setUsage(data)
76
+ setStatus("ready")
77
+ // Persist API data to cache so other processes can read it
78
+ writeUsageCache({ api: data, apiFetchedAt: Date.now(), apiRateLimitUntil: 0 })
79
+ return
80
+ }
81
+ if (retryAfterMs > 0) {
82
+ // Record when we can retry using the server's Retry-After value
83
+ writeUsageCache({ apiRateLimitUntil: Date.now() + retryAfterMs })
84
+ }
85
+ if (!data && retryAfterMs === 0) {
86
+ // No credentials or expired token
87
+ if (!bestUsageFromCache(cache)) {
88
+ setStatus("no-credentials")
89
+ return
90
+ }
91
+ }
92
+ } catch {
93
+ // Network error — fall through to cache
94
+ }
95
+ }
96
+
97
+ // 2. Fall back to cache file (header data from server, or previous API data)
98
+ const best = bestUsageFromCache(cache)
99
+ if (best) {
100
+ setUsage(best)
101
+ setStatus("ready")
102
+ return
103
+ }
104
+
105
+ // Nothing available
106
+ setStatus(usage() ? "ready" : "error")
107
+ }
108
+
109
+ // Initial load
110
+ const cache = readUsageCache()
111
+ const initial = bestUsageFromCache(cache)
112
+ if (initial) {
113
+ setUsage(initial)
114
+ setStatus("ready")
115
+ }
116
+
117
+ // Async refresh for fresh data
118
+ refresh()
119
+
120
+ // Periodic polling
121
+ const timer = setInterval(refresh, pollInterval * 1000)
122
+
123
+ return {
124
+ usage,
125
+ status,
126
+ refresh,
127
+ dispose: () => clearInterval(timer),
128
+ }
129
+ }
130
+
131
+ // ─── Helpers ─────────────────────────────────────────────────────────
132
+
133
+ const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
134
+
135
+ const bar = (ratio: number, width: number): string => {
136
+ const r = clamp01(ratio)
137
+ const size = Math.max(1, width)
138
+ const n = Math.round(r * size)
139
+ return "\u2588".repeat(n) + "\u2591".repeat(size - n)
140
+ }
141
+
142
+ const pct = (ratio: number): string => Math.round(clamp01(ratio) * 100) + "%"
143
+
144
+ const fillWidth = (width: number, ...parts: string[]): number => {
145
+ return Math.max(1, width - parts.reduce((sum, p) => sum + p.length, 0) - 2)
146
+ }
147
+
148
+ function barColor(ratio: number, theme: TuiThemeCurrent) {
149
+ if (ratio > 0.9) return theme.error
150
+ if (ratio > 0.7) return theme.warning
151
+ return theme.primary
152
+ }
153
+
154
+ // ─── Sidebar Component ──────────────────────────────────────────────
155
+
156
+ function UsageSidebar(props: {
157
+ theme: TuiThemeCurrent
158
+ usage: () => UsageData | null
159
+ status: () => UsageStatus
160
+ }) {
161
+ const [barWidth, setBarWidth] = createSignal(20)
162
+
163
+ const fiveHour = createMemo(() => {
164
+ const d = props.usage()
165
+ if (!d?.five_hour) return null
166
+ return {
167
+ ratio: clamp01(d.five_hour.utilization / 100),
168
+ reset: d.five_hour.resets_at,
169
+ }
170
+ })
171
+
172
+ const sevenDay = createMemo(() => {
173
+ const d = props.usage()
174
+ if (!d?.seven_day) return null
175
+ return {
176
+ ratio: clamp01(d.seven_day.utilization / 100),
177
+ reset: d.seven_day.resets_at,
178
+ }
179
+ })
180
+
181
+ const sevenDaySonnet = createMemo(() => {
182
+ const d = props.usage()
183
+ if (!d?.seven_day_sonnet) return null
184
+ return {
185
+ ratio: clamp01(d.seven_day_sonnet.utilization / 100),
186
+ reset: d.seven_day_sonnet.resets_at,
187
+ }
188
+ })
189
+
190
+ const hasData = createMemo(() => fiveHour() !== null || sevenDay() !== null)
191
+
192
+ return (
193
+ <Show when={hasData()}>
194
+ <box
195
+ onSizeChange={function () {
196
+ const next = Math.max(1, this.width)
197
+ setBarWidth((prev) => (prev === next ? prev : next))
198
+ }}
199
+ paddingTop={1}
200
+ width="100%"
201
+ flexDirection="column"
202
+ >
203
+ <text fg={props.theme.text}>
204
+ <b>Claude Usage</b>
205
+ </text>
206
+ <Show when={fiveHour()}>
207
+ {(data) => {
208
+ const label = "5H "
209
+ const suffix = createMemo(() => ` ${pct(data().ratio)}`)
210
+ return (
211
+ <>
212
+ <text>
213
+ <span style={{ fg: props.theme.textMuted }}>{label}</span>
214
+ <span style={{ fg: barColor(data().ratio, props.theme) }}>
215
+ [{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
216
+ </span>
217
+ <span style={{ fg: props.theme.text }}>{suffix()}</span>
218
+ </text>
219
+ <Show when={data().reset}>
220
+ <text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
221
+ </Show>
222
+ </>
223
+ )
224
+ }}
225
+ </Show>
226
+ <Show when={sevenDay()}>
227
+ {(data) => {
228
+ const label = "7D "
229
+ const suffix = createMemo(() => ` ${pct(data().ratio)}`)
230
+ return (
231
+ <>
232
+ <text>
233
+ <span style={{ fg: props.theme.textMuted }}>{label}</span>
234
+ <span style={{ fg: barColor(data().ratio, props.theme) }}>
235
+ [{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
236
+ </span>
237
+ <span style={{ fg: props.theme.text }}>{suffix()}</span>
238
+ </text>
239
+ <Show when={data().reset}>
240
+ <text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
241
+ </Show>
242
+ </>
243
+ )
244
+ }}
245
+ </Show>
246
+ <Show when={sevenDaySonnet()}>
247
+ {(data) => {
248
+ const label = "7D Sonnet "
249
+ const suffix = createMemo(() => ` ${pct(data().ratio)}`)
250
+ return (
251
+ <>
252
+ <text>
253
+ <span style={{ fg: props.theme.textMuted }}>{label}</span>
254
+ <span style={{ fg: barColor(data().ratio, props.theme) }}>
255
+ [{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
256
+ </span>
257
+ <span style={{ fg: props.theme.text }}>{suffix()}</span>
258
+ </text>
259
+ <Show when={data().reset}>
260
+ <text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
261
+ </Show>
262
+ </>
263
+ )
264
+ }}
265
+ </Show>
266
+ </box>
267
+ </Show>
268
+ )
269
+ }
270
+
271
+ // ─── Dialog Component ────────────────────────────────────────────────
272
+
273
+ function UsageDialog(props: {
274
+ api: Api
275
+ usage: () => UsageData | null
276
+ status: () => UsageStatus
277
+ }) {
278
+ const theme = () => props.api.theme.current
279
+
280
+ const lines = createMemo(() => {
281
+ const data = props.usage()
282
+ const st = props.status()
283
+
284
+ if (!data) {
285
+ if (st === "loading" || st === "idle") return ["Loading usage data..."]
286
+ if (st === "no-credentials")
287
+ return ["No OAuth credentials found", "Log in via: claude auth login"]
288
+ return ["Waiting for usage data...", "Send a message first or try again shortly"]
289
+ }
290
+
291
+ const result: Array<{ label: string; pct: string; reset: string }> = []
292
+
293
+ if (data.five_hour) {
294
+ result.push({
295
+ label: "Current session (5h)",
296
+ pct: `${Math.round(data.five_hour.utilization)}% used`,
297
+ reset: formatReset(data.five_hour.resets_at),
298
+ })
299
+ }
300
+ if (data.seven_day) {
301
+ result.push({
302
+ label: "Current week (all models)",
303
+ pct: `${Math.round(data.seven_day.utilization)}% used`,
304
+ reset: formatReset(data.seven_day.resets_at),
305
+ })
306
+ }
307
+ if (data.seven_day_sonnet) {
308
+ result.push({
309
+ label: "Current week (Sonnet)",
310
+ pct: `${Math.round(data.seven_day_sonnet.utilization)}% used`,
311
+ reset: formatReset(data.seven_day_sonnet.resets_at),
312
+ })
313
+ }
314
+ if (data.seven_day_opus) {
315
+ result.push({
316
+ label: "Current week (Opus)",
317
+ pct: `${Math.round(data.seven_day_opus.utilization)}% used`,
318
+ reset: formatReset(data.seven_day_opus.resets_at),
319
+ })
320
+ }
321
+
322
+ return result
323
+ })
324
+
325
+ const extra = createMemo(() => {
326
+ const data = props.usage()
327
+ if (!data?.extra_usage) return null
328
+ if (!data.extra_usage.is_enabled) return { enabled: false, text: "Extra usage: disabled" }
329
+ return {
330
+ enabled: true,
331
+ text:
332
+ `Extra usage: ${Math.round(data.extra_usage.utilization ?? 0)}% used` +
333
+ (data.extra_usage.monthly_limit != null
334
+ ? ` ($${(data.extra_usage.used_credits ?? 0).toFixed(2)} / $${data.extra_usage.monthly_limit.toFixed(2)})`
335
+ : ""),
336
+ }
337
+ })
338
+
339
+ return (
340
+ <box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
341
+ <text fg={theme().text}>
342
+ <b>Claude Subscription Usage</b>
343
+ </text>
344
+ <text fg={theme().textMuted}>─────────────────────────────────</text>
345
+ {(() => {
346
+ const items = lines()
347
+ if (typeof items[0] === "string") {
348
+ return (items as string[]).map((line) => <text fg={theme().textMuted}>{line}</text>)
349
+ }
350
+ return (items as Array<{ label: string; pct: string; reset: string }>).map((item) => (
351
+ <box paddingTop={1}>
352
+ <text fg={theme().text}>{item.label}</text>
353
+ <text>
354
+ <span style={{ fg: theme().primary }}>{item.pct}</span>
355
+ </text>
356
+ <Show when={item.reset}>
357
+ <text fg={theme().textMuted}>{item.reset}</text>
358
+ </Show>
359
+ </box>
360
+ ))
361
+ })()}
362
+ <Show when={extra()}>
363
+ {(ex) => (
364
+ <box paddingTop={1}>
365
+ <text fg={ex().enabled ? theme().warning : theme().textMuted}>{ex().text}</text>
366
+ </box>
367
+ )}
368
+ </Show>
369
+ </box>
370
+ )
371
+ }
372
+
373
+ // ─── Plugin ──────────────────────────────────────────────────────────
374
+
375
+ const tui: TuiPlugin = async (api, options) => {
376
+ const config = cfg(rec(options))
377
+ if (!config.enabled) return
378
+
379
+ const store = createUsageStore(config.poll_interval)
380
+ api.lifecycle.onDispose(store.dispose)
381
+
382
+ // Refresh when session goes idle (inference completed — server just wrote new headers)
383
+ const offEvent = api.event.on("session.idle", () => {
384
+ setTimeout(store.refresh, 1000)
385
+ })
386
+ api.lifecycle.onDispose(offEvent)
387
+
388
+ // Register /usage command
389
+ api.command.register(() => [
390
+ {
391
+ title: "Claude subscription usage",
392
+ value: "anthropic-sdk.usage",
393
+ category: "Claude",
394
+ slash: {
395
+ name: "usage",
396
+ },
397
+ onSelect() {
398
+ store.refresh()
399
+ api.ui.dialog.replace(() => (
400
+ <UsageDialog api={api} usage={store.usage} status={store.status} />
401
+ ))
402
+ },
403
+ },
404
+ ])
405
+
406
+ // Register sidebar slot
407
+ if (config.sidebar) {
408
+ api.slots.register({
409
+ order: 110,
410
+ slots: {
411
+ sidebar_content(ctx) {
412
+ return (
413
+ <UsageSidebar theme={ctx.theme.current} usage={store.usage} status={store.status} />
414
+ )
415
+ },
416
+ },
417
+ })
418
+ }
419
+ }
420
+
421
+ const plugin: TuiPluginModule & { id: string } = {
422
+ id,
423
+ tui,
424
+ }
425
+
426
+ export default plugin