@xtruder/opencode-claude-max-plugin 0.1.17 → 0.2.2
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 +56 -18
- package/build/index.d.ts.map +1 -1
- package/build/index.js +256 -336
- package/build/model.d.ts.map +1 -1
- package/build/prompt.d.ts.map +1 -1
- package/build/server.d.ts +14 -0
- package/build/server.d.ts.map +1 -0
- package/build/server.js +1212 -0
- package/build/stream.d.ts.map +1 -1
- package/build/usage.d.ts +72 -5
- package/build/usage.d.ts.map +1 -1
- package/package.json +40 -4
- package/src/credentials.ts +156 -0
- package/src/tui.tsx +396 -0
- package/src/usage.ts +331 -0
- package/build/usage-cache.d.ts +0 -15
- package/build/usage-cache.d.ts.map +0 -1
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
/** @jsxImportSource @opentui/solid */
|
|
3
|
+
import { Show, createMemo, createSignal } from "solid-js"
|
|
4
|
+
import type {
|
|
5
|
+
TuiPlugin,
|
|
6
|
+
TuiPluginApi,
|
|
7
|
+
TuiPluginModule,
|
|
8
|
+
TuiThemeCurrent,
|
|
9
|
+
} from "@opencode-ai/plugin/tui"
|
|
10
|
+
import {
|
|
11
|
+
type UsageData,
|
|
12
|
+
bestUsageFromCache,
|
|
13
|
+
fetchUsage,
|
|
14
|
+
formatReset,
|
|
15
|
+
readUsageCache,
|
|
16
|
+
shouldFetchApi,
|
|
17
|
+
writeUsageCache,
|
|
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 hasData = createMemo(() => fiveHour() !== null || sevenDay() !== null)
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<Show when={hasData()}>
|
|
185
|
+
<box
|
|
186
|
+
onSizeChange={function () {
|
|
187
|
+
const next = Math.max(1, this.width)
|
|
188
|
+
setBarWidth((prev) => (prev === next ? prev : next))
|
|
189
|
+
}}
|
|
190
|
+
width="100%"
|
|
191
|
+
flexDirection="column"
|
|
192
|
+
>
|
|
193
|
+
<text fg={props.theme.text}>
|
|
194
|
+
<b>Claude Usage</b>
|
|
195
|
+
</text>
|
|
196
|
+
<Show when={fiveHour()}>
|
|
197
|
+
{(data) => {
|
|
198
|
+
const label = "5H "
|
|
199
|
+
const suffix = createMemo(() => ` ${pct(data().ratio)}`)
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
<text>
|
|
203
|
+
<span style={{ fg: props.theme.textMuted }}>{label}</span>
|
|
204
|
+
<span style={{ fg: barColor(data().ratio, props.theme) }}>
|
|
205
|
+
[{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
|
|
206
|
+
</span>
|
|
207
|
+
<span style={{ fg: props.theme.text }}>{suffix()}</span>
|
|
208
|
+
</text>
|
|
209
|
+
<Show when={data().reset}>
|
|
210
|
+
<text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
|
|
211
|
+
</Show>
|
|
212
|
+
</>
|
|
213
|
+
)
|
|
214
|
+
}}
|
|
215
|
+
</Show>
|
|
216
|
+
<Show when={sevenDay()}>
|
|
217
|
+
{(data) => {
|
|
218
|
+
const label = "7D "
|
|
219
|
+
const suffix = createMemo(() => ` ${pct(data().ratio)}`)
|
|
220
|
+
return (
|
|
221
|
+
<>
|
|
222
|
+
<text>
|
|
223
|
+
<span style={{ fg: props.theme.textMuted }}>{label}</span>
|
|
224
|
+
<span style={{ fg: barColor(data().ratio, props.theme) }}>
|
|
225
|
+
[{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
|
|
226
|
+
</span>
|
|
227
|
+
<span style={{ fg: props.theme.text }}>{suffix()}</span>
|
|
228
|
+
</text>
|
|
229
|
+
<Show when={data().reset}>
|
|
230
|
+
<text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
|
|
231
|
+
</Show>
|
|
232
|
+
</>
|
|
233
|
+
)
|
|
234
|
+
}}
|
|
235
|
+
</Show>
|
|
236
|
+
</box>
|
|
237
|
+
</Show>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Dialog Component ────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
function UsageDialog(props: {
|
|
244
|
+
api: Api
|
|
245
|
+
usage: () => UsageData | null
|
|
246
|
+
status: () => UsageStatus
|
|
247
|
+
}) {
|
|
248
|
+
const theme = () => props.api.theme.current
|
|
249
|
+
|
|
250
|
+
const lines = createMemo(() => {
|
|
251
|
+
const data = props.usage()
|
|
252
|
+
const st = props.status()
|
|
253
|
+
|
|
254
|
+
if (!data) {
|
|
255
|
+
if (st === "loading" || st === "idle") return ["Loading usage data..."]
|
|
256
|
+
if (st === "no-credentials")
|
|
257
|
+
return ["No OAuth credentials found", "Log in via: claude auth login"]
|
|
258
|
+
return ["Waiting for usage data...", "Send a message first or try again shortly"]
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const result: Array<{ label: string; pct: string; reset: string }> = []
|
|
262
|
+
|
|
263
|
+
if (data.five_hour) {
|
|
264
|
+
result.push({
|
|
265
|
+
label: "Current session (5h)",
|
|
266
|
+
pct: `${Math.round(data.five_hour.utilization)}% used`,
|
|
267
|
+
reset: formatReset(data.five_hour.resets_at),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
if (data.seven_day) {
|
|
271
|
+
result.push({
|
|
272
|
+
label: "Current week (all models)",
|
|
273
|
+
pct: `${Math.round(data.seven_day.utilization)}% used`,
|
|
274
|
+
reset: formatReset(data.seven_day.resets_at),
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
if (data.seven_day_sonnet) {
|
|
278
|
+
result.push({
|
|
279
|
+
label: "Current week (Sonnet)",
|
|
280
|
+
pct: `${Math.round(data.seven_day_sonnet.utilization)}% used`,
|
|
281
|
+
reset: formatReset(data.seven_day_sonnet.resets_at),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
if (data.seven_day_opus) {
|
|
285
|
+
result.push({
|
|
286
|
+
label: "Current week (Opus)",
|
|
287
|
+
pct: `${Math.round(data.seven_day_opus.utilization)}% used`,
|
|
288
|
+
reset: formatReset(data.seven_day_opus.resets_at),
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return result
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
const extra = createMemo(() => {
|
|
296
|
+
const data = props.usage()
|
|
297
|
+
if (!data?.extra_usage) return null
|
|
298
|
+
if (!data.extra_usage.is_enabled) return { enabled: false, text: "Extra usage: disabled" }
|
|
299
|
+
return {
|
|
300
|
+
enabled: true,
|
|
301
|
+
text:
|
|
302
|
+
`Extra usage: ${Math.round(data.extra_usage.utilization ?? 0)}% used` +
|
|
303
|
+
(data.extra_usage.monthly_limit != null
|
|
304
|
+
? ` ($${(data.extra_usage.used_credits ?? 0).toFixed(2)} / $${data.extra_usage.monthly_limit.toFixed(2)})`
|
|
305
|
+
: ""),
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
311
|
+
<text fg={theme().text}>
|
|
312
|
+
<b>Claude Subscription Usage</b>
|
|
313
|
+
</text>
|
|
314
|
+
<text fg={theme().textMuted}>─────────────────────────────────</text>
|
|
315
|
+
{(() => {
|
|
316
|
+
const items = lines()
|
|
317
|
+
if (typeof items[0] === "string") {
|
|
318
|
+
return (items as string[]).map((line) => <text fg={theme().textMuted}>{line}</text>)
|
|
319
|
+
}
|
|
320
|
+
return (items as Array<{ label: string; pct: string; reset: string }>).map((item) => (
|
|
321
|
+
<box paddingTop={1}>
|
|
322
|
+
<text fg={theme().text}>{item.label}</text>
|
|
323
|
+
<text>
|
|
324
|
+
<span style={{ fg: theme().primary }}>{item.pct}</span>
|
|
325
|
+
</text>
|
|
326
|
+
<Show when={item.reset}>
|
|
327
|
+
<text fg={theme().textMuted}>{item.reset}</text>
|
|
328
|
+
</Show>
|
|
329
|
+
</box>
|
|
330
|
+
))
|
|
331
|
+
})()}
|
|
332
|
+
<Show when={extra()}>
|
|
333
|
+
{(ex) => (
|
|
334
|
+
<box paddingTop={1}>
|
|
335
|
+
<text fg={ex().enabled ? theme().warning : theme().textMuted}>{ex().text}</text>
|
|
336
|
+
</box>
|
|
337
|
+
)}
|
|
338
|
+
</Show>
|
|
339
|
+
</box>
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Plugin ──────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
346
|
+
const config = cfg(rec(options))
|
|
347
|
+
if (!config.enabled) return
|
|
348
|
+
|
|
349
|
+
const store = createUsageStore(config.poll_interval)
|
|
350
|
+
api.lifecycle.onDispose(store.dispose)
|
|
351
|
+
|
|
352
|
+
// Refresh when session goes idle (inference completed — server just wrote new headers)
|
|
353
|
+
const offEvent = api.event.on("session.idle", () => {
|
|
354
|
+
setTimeout(store.refresh, 1000)
|
|
355
|
+
})
|
|
356
|
+
api.lifecycle.onDispose(offEvent)
|
|
357
|
+
|
|
358
|
+
// Register /usage command
|
|
359
|
+
api.command.register(() => [
|
|
360
|
+
{
|
|
361
|
+
title: "Claude subscription usage",
|
|
362
|
+
value: "anthropic-sdk.usage",
|
|
363
|
+
category: "Claude",
|
|
364
|
+
slash: {
|
|
365
|
+
name: "usage",
|
|
366
|
+
},
|
|
367
|
+
onSelect() {
|
|
368
|
+
store.refresh()
|
|
369
|
+
api.ui.dialog.replace(() => (
|
|
370
|
+
<UsageDialog api={api} usage={store.usage} status={store.status} />
|
|
371
|
+
))
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
])
|
|
375
|
+
|
|
376
|
+
// Register sidebar slot
|
|
377
|
+
if (config.sidebar) {
|
|
378
|
+
api.slots.register({
|
|
379
|
+
order: 110,
|
|
380
|
+
slots: {
|
|
381
|
+
sidebar_content(ctx) {
|
|
382
|
+
return (
|
|
383
|
+
<UsageSidebar theme={ctx.theme.current} usage={store.usage} status={store.status} />
|
|
384
|
+
)
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
392
|
+
id,
|
|
393
|
+
tui,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export default plugin
|