@xtruder/opencode-claude-max-plugin 0.4.5 → 2.0.0-rc.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 +109 -124
- package/build/index.d.ts +514 -19
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -1818
- package/build/model.d.ts +0 -5
- package/build/model.d.ts.map +1 -1
- package/build/server.d.ts +4 -11
- package/build/server.d.ts.map +1 -1
- package/build/server.js +68 -1818
- package/build/src-Dvxl6Bub.js +1527 -0
- package/build/tui-state.d.ts +12 -0
- package/build/tui-state.d.ts.map +1 -0
- package/build/tui.d.ts +11 -3
- package/build/tui.d.ts.map +1 -1
- package/build/tui.js +239 -0
- package/build/usage-pZdEiX94.js +336 -0
- package/build/usage.d.ts +1 -1
- package/build/usage.d.ts.map +1 -1
- package/package.json +28 -42
- package/src/credentials.ts +0 -156
- package/src/tui.tsx +0 -579
- package/src/usage.ts +0 -331
package/src/tui.tsx
DELETED
|
@@ -1,579 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type {
|
|
3
|
-
TuiPlugin,
|
|
4
|
-
TuiPluginApi,
|
|
5
|
-
TuiPluginModule,
|
|
6
|
-
TuiThemeCurrent,
|
|
7
|
-
} from "@opencode-ai/plugin/tui"
|
|
8
|
-
import { type Accessor, Show, createMemo, createSignal } from "solid-js"
|
|
9
|
-
import {
|
|
10
|
-
type UsageData,
|
|
11
|
-
bestUsageFromCache,
|
|
12
|
-
fetchUsage,
|
|
13
|
-
formatReset,
|
|
14
|
-
readUsageCache,
|
|
15
|
-
shouldFetchApi,
|
|
16
|
-
writeUsageCache,
|
|
17
|
-
} from "./usage.ts"
|
|
18
|
-
|
|
19
|
-
const id = "anthropic-sdk-usage"
|
|
20
|
-
|
|
21
|
-
type Api = TuiPluginApi
|
|
22
|
-
|
|
23
|
-
// ─── Config ──────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
type Cfg = {
|
|
26
|
-
enabled: boolean
|
|
27
|
-
sidebar: boolean
|
|
28
|
-
poll_interval: number
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const bool = (value: unknown, fallback: boolean) => {
|
|
32
|
-
if (typeof value === "boolean") return value
|
|
33
|
-
if (typeof value === "string") return value === "true"
|
|
34
|
-
return fallback
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const num = (value: unknown, fallback: number) => {
|
|
38
|
-
if (typeof value === "number") return value
|
|
39
|
-
if (typeof value === "string" && value.length > 0) {
|
|
40
|
-
const n = Number(value)
|
|
41
|
-
if (!Number.isNaN(n)) return n
|
|
42
|
-
}
|
|
43
|
-
return fallback
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const rec = (value: unknown) => {
|
|
47
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
48
|
-
return Object.fromEntries(Object.entries(value))
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const cfg = (opts: Record<string, unknown> | undefined): Cfg => ({
|
|
52
|
-
enabled: bool(opts?.enabled, true),
|
|
53
|
-
sidebar: bool(opts?.sidebar, true),
|
|
54
|
-
poll_interval: Math.max(10, num(opts?.poll_interval, 60)),
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
// ─── Usage store ─────────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
type UsageStatus = "idle" | "loading" | "ready" | "no-credentials" | "error"
|
|
60
|
-
|
|
61
|
-
function createUsageStore(pollInterval: number) {
|
|
62
|
-
const [usage, setUsage] = createSignal<UsageData | null>(null)
|
|
63
|
-
const [status, setStatus] = createSignal<UsageStatus>("idle")
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Refresh usage data. Priority:
|
|
67
|
-
* 1. Try the usage API (richer data, per-model breakdown) — unless rate limited
|
|
68
|
-
* 2. Fall back to cache file (updated by server from response headers)
|
|
69
|
-
*/
|
|
70
|
-
const refresh = async () => {
|
|
71
|
-
const cache = readUsageCache()
|
|
72
|
-
|
|
73
|
-
// 1. Try the API if not rate limited and TTL expired
|
|
74
|
-
if (shouldFetchApi(cache)) {
|
|
75
|
-
setStatus(usage() ? "ready" : "loading")
|
|
76
|
-
try {
|
|
77
|
-
const { data, retryAfterMs } = await fetchUsage()
|
|
78
|
-
if (data) {
|
|
79
|
-
setUsage(data)
|
|
80
|
-
setStatus("ready")
|
|
81
|
-
// Persist API data to cache so other processes can read it
|
|
82
|
-
writeUsageCache({ api: data, apiFetchedAt: Date.now(), apiRateLimitUntil: 0 })
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
if (retryAfterMs > 0) {
|
|
86
|
-
// Record when we can retry using the server's Retry-After value
|
|
87
|
-
writeUsageCache({ apiRateLimitUntil: Date.now() + retryAfterMs })
|
|
88
|
-
}
|
|
89
|
-
if (!data && retryAfterMs === 0) {
|
|
90
|
-
// No credentials or expired token
|
|
91
|
-
if (!bestUsageFromCache(cache)) {
|
|
92
|
-
setStatus("no-credentials")
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
} catch {
|
|
97
|
-
// Network error — fall through to cache
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// 2. Fall back to cache file (header data from server, or previous API data)
|
|
102
|
-
const best = bestUsageFromCache(cache)
|
|
103
|
-
if (best) {
|
|
104
|
-
setUsage(best)
|
|
105
|
-
setStatus("ready")
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Nothing available
|
|
110
|
-
setStatus(usage() ? "ready" : "error")
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Initial load
|
|
114
|
-
const cache = readUsageCache()
|
|
115
|
-
const initial = bestUsageFromCache(cache)
|
|
116
|
-
if (initial) {
|
|
117
|
-
setUsage(initial)
|
|
118
|
-
setStatus("ready")
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Async refresh for fresh data
|
|
122
|
-
refresh()
|
|
123
|
-
|
|
124
|
-
// Periodic polling
|
|
125
|
-
const timer = setInterval(refresh, pollInterval * 1000)
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
usage,
|
|
129
|
-
status,
|
|
130
|
-
refresh,
|
|
131
|
-
dispose: () => clearInterval(timer),
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ─── Fallback store ──────────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Watches for server-side model fallback after a classifier refusal.
|
|
139
|
-
*
|
|
140
|
-
* The provider attaches a display-only `anthropic.servedBy` object to part
|
|
141
|
-
* metadata when a response was served by a fallback model (see stream.ts).
|
|
142
|
-
* OpenCode persists part metadata and forwards it here via
|
|
143
|
-
* `message.part.updated` — no side-channel store is needed.
|
|
144
|
-
*
|
|
145
|
-
* Sticky routing is keyed on the conversation prefix server-side, so the
|
|
146
|
-
* fallback state is PER SESSION — a refusal in one session says nothing
|
|
147
|
-
* about another.
|
|
148
|
-
*
|
|
149
|
-
* The sidebar state is DERIVED from the session's persisted messages (via
|
|
150
|
-
* api.state, OpenCode's synced replica) rather than accumulated from live
|
|
151
|
-
* events — so it is correct after a TUI restart or when opening an old
|
|
152
|
-
* session. The event subscription only drives the live toast, deduped with
|
|
153
|
-
* an in-memory Set (part events never replay across restarts).
|
|
154
|
-
*/
|
|
155
|
-
type FallbackInfo = {
|
|
156
|
-
from: string
|
|
157
|
-
to: string
|
|
158
|
-
kind: "fallback" | "sticky"
|
|
159
|
-
at: number
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const shortModel = (model: string) => model.replace(/^claude-/, "")
|
|
163
|
-
|
|
164
|
-
// Absolute time — stable without periodic re-rendering ("ago" phrasing
|
|
165
|
-
// would freeze between reactive updates).
|
|
166
|
-
const atTime = (at: number) =>
|
|
167
|
-
new Date(at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
|
168
|
-
|
|
169
|
-
function createFallbackStore(api: Api) {
|
|
170
|
-
// Dedupe the repeated part-update events that fire as a part streams.
|
|
171
|
-
const seenParts = new Set<string>()
|
|
172
|
-
|
|
173
|
-
/** Extract the servedBy marker from one assistant message's parts. */
|
|
174
|
-
const markerOf = (msg: any): FallbackInfo | null => {
|
|
175
|
-
let latest: FallbackInfo | null = null
|
|
176
|
-
for (const part of api.state.part(msg.id)) {
|
|
177
|
-
const servedBy = (part as any)?.metadata?.anthropic?.servedBy
|
|
178
|
-
if (!servedBy?.to) continue
|
|
179
|
-
const time = (part as any)?.time
|
|
180
|
-
const at = time?.end ?? time?.start ?? msg.time?.created ?? 0
|
|
181
|
-
latest = {
|
|
182
|
-
from: String(servedBy.from ?? "unknown"),
|
|
183
|
-
to: String(servedBy.to),
|
|
184
|
-
kind: servedBy.kind === "sticky" ? "sticky" : "fallback",
|
|
185
|
-
at,
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return latest
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Walk a session's messages newest-first, yielding real assistant turns
|
|
193
|
-
* (skipping title-generation / compaction summaries, which never carry
|
|
194
|
-
* routing markers and would mask the real last turn).
|
|
195
|
-
*/
|
|
196
|
-
function* assistantTurns(sessionID: string, beforeMessageID?: string) {
|
|
197
|
-
const messages = api.state.session.messages(sessionID)
|
|
198
|
-
let skipping = beforeMessageID !== undefined
|
|
199
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
200
|
-
const msg = messages[i] as any
|
|
201
|
-
if (skipping) {
|
|
202
|
-
if (msg?.id === beforeMessageID) skipping = false
|
|
203
|
-
continue
|
|
204
|
-
}
|
|
205
|
-
if (msg?.role !== "assistant") continue
|
|
206
|
-
if (msg.summary) continue
|
|
207
|
-
yield msg
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Live toast — only on the TRANSITION into fallback. A conversation that
|
|
212
|
-
// trips the classifier every turn would otherwise toast on every message;
|
|
213
|
-
// the sidebar line covers the continuing state. Sticky follow-ups never
|
|
214
|
-
// toast for the same reason.
|
|
215
|
-
const off = api.event.on("message.part.updated", (event) => {
|
|
216
|
-
const part = (event as any).properties?.part
|
|
217
|
-
const sessionID = (event as any).properties?.sessionID ?? part?.sessionID
|
|
218
|
-
const servedBy = part?.metadata?.anthropic?.servedBy
|
|
219
|
-
if (!servedBy?.to || !part?.id || !part?.messageID || !sessionID) return
|
|
220
|
-
if (seenParts.has(part.id)) return
|
|
221
|
-
seenParts.add(part.id)
|
|
222
|
-
if (servedBy.kind === "sticky") return
|
|
223
|
-
|
|
224
|
-
// Was the previous completed assistant turn already fallback-served?
|
|
225
|
-
for (const msg of assistantTurns(sessionID, part.messageID)) {
|
|
226
|
-
if (markerOf(msg)) return // continuation, not a transition
|
|
227
|
-
if (msg.time?.completed) break // last known state was the primary model
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
api.ui.toast({
|
|
231
|
-
variant: "warning",
|
|
232
|
-
title: "Model fallback",
|
|
233
|
-
message: `${shortModel(String(servedBy.from ?? "unknown"))} refused — answered by ${shortModel(String(servedBy.to))}`,
|
|
234
|
-
duration: 6000,
|
|
235
|
-
})
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Fallback state for one session, derived from its message parts — a pure
|
|
240
|
-
* reactive derivation that re-evaluates only when session state changes.
|
|
241
|
-
*
|
|
242
|
-
* Only the MOST RECENT *settled* assistant turn is consulted: sticky
|
|
243
|
-
* routing re-emits a servedBy marker on every turn while active, and a
|
|
244
|
-
* turn that routed back to the primary model carries no marker. Looking
|
|
245
|
-
* further back would resurrect a stale fallback after the session has
|
|
246
|
-
* already returned to the requested model.
|
|
247
|
-
*
|
|
248
|
-
* A still-streaming turn without a marker is "unknown", not "no
|
|
249
|
-
* fallback" — the previous completed turn's state keeps showing until
|
|
250
|
-
* the new turn settles (otherwise the line flickers off on every send
|
|
251
|
-
* and back on when the refusal hits mid-stream).
|
|
252
|
-
*
|
|
253
|
-
* No time window: "the latest turn was served by X" is a fact that stays
|
|
254
|
-
* true until the next turn says otherwise. Whether the server's sticky
|
|
255
|
-
* routing is still active is unobservable — we don't guess.
|
|
256
|
-
*/
|
|
257
|
-
const active = (sessionID: string | undefined): FallbackInfo | null => {
|
|
258
|
-
if (!sessionID) return null
|
|
259
|
-
for (const msg of assistantTurns(sessionID)) {
|
|
260
|
-
const marker = markerOf(msg)
|
|
261
|
-
if (marker) return marker
|
|
262
|
-
// No marker on a finished turn → currently served by the requested
|
|
263
|
-
// model. No marker on a streaming turn → not known yet, keep looking
|
|
264
|
-
// at the previous turn.
|
|
265
|
-
if (msg.time?.completed) return null
|
|
266
|
-
}
|
|
267
|
-
return null
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return { active, dispose: off }
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
274
|
-
|
|
275
|
-
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
|
|
276
|
-
|
|
277
|
-
const bar = (ratio: number, width: number): string => {
|
|
278
|
-
const r = clamp01(ratio)
|
|
279
|
-
const size = Math.max(1, width)
|
|
280
|
-
const n = Math.round(r * size)
|
|
281
|
-
return "\u2588".repeat(n) + "\u2591".repeat(size - n)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const pct = (ratio: number): string => Math.round(clamp01(ratio) * 100) + "%"
|
|
285
|
-
|
|
286
|
-
const fillWidth = (width: number, ...parts: string[]): number => {
|
|
287
|
-
return Math.max(1, width - parts.reduce((sum, p) => sum + p.length, 0) - 2)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function barColor(ratio: number, theme: TuiThemeCurrent) {
|
|
291
|
-
if (ratio > 0.9) return theme.error
|
|
292
|
-
if (ratio > 0.7) return theme.warning
|
|
293
|
-
return theme.primary
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ─── Sidebar Component ──────────────────────────────────────────────
|
|
297
|
-
|
|
298
|
-
function UsageSidebar(props: {
|
|
299
|
-
theme: TuiThemeCurrent
|
|
300
|
-
usage: () => UsageData | null
|
|
301
|
-
status: () => UsageStatus
|
|
302
|
-
}) {
|
|
303
|
-
const [barWidth, setBarWidth] = createSignal(20)
|
|
304
|
-
|
|
305
|
-
const fiveHour = createMemo(() => {
|
|
306
|
-
const d = props.usage()
|
|
307
|
-
if (!d?.five_hour) return null
|
|
308
|
-
return {
|
|
309
|
-
ratio: clamp01(d.five_hour.utilization / 100),
|
|
310
|
-
reset: d.five_hour.resets_at,
|
|
311
|
-
}
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
const sevenDay = createMemo(() => {
|
|
315
|
-
const d = props.usage()
|
|
316
|
-
if (!d?.seven_day) return null
|
|
317
|
-
return {
|
|
318
|
-
ratio: clamp01(d.seven_day.utilization / 100),
|
|
319
|
-
reset: d.seven_day.resets_at,
|
|
320
|
-
}
|
|
321
|
-
})
|
|
322
|
-
|
|
323
|
-
const hasData = createMemo(() => fiveHour() !== null || sevenDay() !== null)
|
|
324
|
-
|
|
325
|
-
return (
|
|
326
|
-
<Show when={hasData()}>
|
|
327
|
-
<box
|
|
328
|
-
onSizeChange={function () {
|
|
329
|
-
const next = Math.max(1, this.width)
|
|
330
|
-
setBarWidth((prev) => (prev === next ? prev : next))
|
|
331
|
-
}}
|
|
332
|
-
width="100%"
|
|
333
|
-
flexDirection="column"
|
|
334
|
-
>
|
|
335
|
-
<text fg={props.theme.text}>
|
|
336
|
-
<b>Claude Usage</b>
|
|
337
|
-
</text>
|
|
338
|
-
<Show when={fiveHour()}>
|
|
339
|
-
{(data: Accessor<{ ratio: number; reset: string | null }>) => {
|
|
340
|
-
const label = "5H "
|
|
341
|
-
const suffix = createMemo(() => ` ${pct(data().ratio)}`)
|
|
342
|
-
return (
|
|
343
|
-
<>
|
|
344
|
-
<text>
|
|
345
|
-
<span style={{ fg: props.theme.textMuted }}>{label}</span>
|
|
346
|
-
<span style={{ fg: barColor(data().ratio, props.theme) }}>
|
|
347
|
-
[{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
|
|
348
|
-
</span>
|
|
349
|
-
<span style={{ fg: props.theme.text }}>{suffix()}</span>
|
|
350
|
-
</text>
|
|
351
|
-
<Show when={data().reset}>
|
|
352
|
-
<text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
|
|
353
|
-
</Show>
|
|
354
|
-
</>
|
|
355
|
-
)
|
|
356
|
-
}}
|
|
357
|
-
</Show>
|
|
358
|
-
<Show when={sevenDay()}>
|
|
359
|
-
{(data: Accessor<{ ratio: number; reset: string | null }>) => {
|
|
360
|
-
const label = "7D "
|
|
361
|
-
const suffix = createMemo(() => ` ${pct(data().ratio)}`)
|
|
362
|
-
return (
|
|
363
|
-
<>
|
|
364
|
-
<text>
|
|
365
|
-
<span style={{ fg: props.theme.textMuted }}>{label}</span>
|
|
366
|
-
<span style={{ fg: barColor(data().ratio, props.theme) }}>
|
|
367
|
-
[{bar(data().ratio, fillWidth(barWidth(), label, suffix()))}]
|
|
368
|
-
</span>
|
|
369
|
-
<span style={{ fg: props.theme.text }}>{suffix()}</span>
|
|
370
|
-
</text>
|
|
371
|
-
<Show when={data().reset}>
|
|
372
|
-
<text fg={props.theme.textMuted}>{formatReset(data().reset)}</text>
|
|
373
|
-
</Show>
|
|
374
|
-
</>
|
|
375
|
-
)
|
|
376
|
-
}}
|
|
377
|
-
</Show>
|
|
378
|
-
</box>
|
|
379
|
-
</Show>
|
|
380
|
-
)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ─── Fallback Sidebar Component ──────────────────────────────────────
|
|
384
|
-
|
|
385
|
-
function FallbackSidebar(props: {
|
|
386
|
-
theme: TuiThemeCurrent
|
|
387
|
-
sessionID: string | undefined
|
|
388
|
-
active: (sessionID: string | undefined) => FallbackInfo | null
|
|
389
|
-
}) {
|
|
390
|
-
return (
|
|
391
|
-
<Show when={props.active(props.sessionID)}>
|
|
392
|
-
{(info: Accessor<FallbackInfo>) => (
|
|
393
|
-
<box width="100%" flexDirection="column">
|
|
394
|
-
<text fg={props.theme.warning}>
|
|
395
|
-
<b>Model Fallback</b>
|
|
396
|
-
</text>
|
|
397
|
-
<text fg={props.theme.text}>
|
|
398
|
-
{shortModel(info().from)} → {shortModel(info().to)}
|
|
399
|
-
</text>
|
|
400
|
-
<text fg={props.theme.textMuted}>
|
|
401
|
-
{info().kind === "sticky"
|
|
402
|
-
? `sticky-routed at ${atTime(info().at)}`
|
|
403
|
-
: `refused at ${atTime(info().at)}`}
|
|
404
|
-
</text>
|
|
405
|
-
</box>
|
|
406
|
-
)}
|
|
407
|
-
</Show>
|
|
408
|
-
)
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// ─── Dialog Component ────────────────────────────────────────────────
|
|
412
|
-
|
|
413
|
-
function UsageDialog(props: {
|
|
414
|
-
api: Api
|
|
415
|
-
usage: () => UsageData | null
|
|
416
|
-
status: () => UsageStatus
|
|
417
|
-
}) {
|
|
418
|
-
const theme = () => props.api.theme.current
|
|
419
|
-
|
|
420
|
-
const lines = createMemo(() => {
|
|
421
|
-
const data = props.usage()
|
|
422
|
-
const st = props.status()
|
|
423
|
-
|
|
424
|
-
if (!data) {
|
|
425
|
-
if (st === "loading" || st === "idle") return ["Loading usage data..."]
|
|
426
|
-
if (st === "no-credentials")
|
|
427
|
-
return ["No OAuth credentials found", "Log in via: claude auth login"]
|
|
428
|
-
return ["Waiting for usage data...", "Send a message first or try again shortly"]
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const result: Array<{ label: string; pct: string; reset: string }> = []
|
|
432
|
-
|
|
433
|
-
if (data.five_hour) {
|
|
434
|
-
result.push({
|
|
435
|
-
label: "Current session (5h)",
|
|
436
|
-
pct: `${Math.round(data.five_hour.utilization)}% used`,
|
|
437
|
-
reset: formatReset(data.five_hour.resets_at),
|
|
438
|
-
})
|
|
439
|
-
}
|
|
440
|
-
if (data.seven_day) {
|
|
441
|
-
result.push({
|
|
442
|
-
label: "Current week (all models)",
|
|
443
|
-
pct: `${Math.round(data.seven_day.utilization)}% used`,
|
|
444
|
-
reset: formatReset(data.seven_day.resets_at),
|
|
445
|
-
})
|
|
446
|
-
}
|
|
447
|
-
if (data.seven_day_sonnet) {
|
|
448
|
-
result.push({
|
|
449
|
-
label: "Current week (Sonnet)",
|
|
450
|
-
pct: `${Math.round(data.seven_day_sonnet.utilization)}% used`,
|
|
451
|
-
reset: formatReset(data.seven_day_sonnet.resets_at),
|
|
452
|
-
})
|
|
453
|
-
}
|
|
454
|
-
if (data.seven_day_opus) {
|
|
455
|
-
result.push({
|
|
456
|
-
label: "Current week (Opus)",
|
|
457
|
-
pct: `${Math.round(data.seven_day_opus.utilization)}% used`,
|
|
458
|
-
reset: formatReset(data.seven_day_opus.resets_at),
|
|
459
|
-
})
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
return result
|
|
463
|
-
})
|
|
464
|
-
|
|
465
|
-
const extra = createMemo(() => {
|
|
466
|
-
const data = props.usage()
|
|
467
|
-
if (!data?.extra_usage) return null
|
|
468
|
-
if (!data.extra_usage.is_enabled) return { enabled: false, text: "Extra usage: disabled" }
|
|
469
|
-
return {
|
|
470
|
-
enabled: true,
|
|
471
|
-
text:
|
|
472
|
-
`Extra usage: ${Math.round(data.extra_usage.utilization ?? 0)}% used` +
|
|
473
|
-
(data.extra_usage.monthly_limit != null
|
|
474
|
-
? ` ($${(data.extra_usage.used_credits ?? 0).toFixed(2)} / $${data.extra_usage.monthly_limit.toFixed(2)})`
|
|
475
|
-
: ""),
|
|
476
|
-
}
|
|
477
|
-
})
|
|
478
|
-
|
|
479
|
-
return (
|
|
480
|
-
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
481
|
-
<text fg={theme().text}>
|
|
482
|
-
<b>Claude Subscription Usage</b>
|
|
483
|
-
</text>
|
|
484
|
-
<text fg={theme().textMuted}>─────────────────────────────────</text>
|
|
485
|
-
{(() => {
|
|
486
|
-
const items = lines()
|
|
487
|
-
if (typeof items[0] === "string") {
|
|
488
|
-
return (items as string[]).map((line) => <text fg={theme().textMuted}>{line}</text>)
|
|
489
|
-
}
|
|
490
|
-
return (items as Array<{ label: string; pct: string; reset: string }>).map((item) => (
|
|
491
|
-
<box paddingTop={1}>
|
|
492
|
-
<text fg={theme().text}>{item.label}</text>
|
|
493
|
-
<text>
|
|
494
|
-
<span style={{ fg: theme().primary }}>{item.pct}</span>
|
|
495
|
-
</text>
|
|
496
|
-
<Show when={item.reset}>
|
|
497
|
-
<text fg={theme().textMuted}>{item.reset}</text>
|
|
498
|
-
</Show>
|
|
499
|
-
</box>
|
|
500
|
-
))
|
|
501
|
-
})()}
|
|
502
|
-
<Show when={extra()}>
|
|
503
|
-
{(ex: Accessor<{ enabled: boolean; text: string }>) => (
|
|
504
|
-
<box paddingTop={1}>
|
|
505
|
-
<text fg={ex().enabled ? theme().warning : theme().textMuted}>{ex().text}</text>
|
|
506
|
-
</box>
|
|
507
|
-
)}
|
|
508
|
-
</Show>
|
|
509
|
-
</box>
|
|
510
|
-
)
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
// ─── Plugin ──────────────────────────────────────────────────────────
|
|
514
|
-
|
|
515
|
-
const tui: TuiPlugin = async (api, options) => {
|
|
516
|
-
const config = cfg(rec(options))
|
|
517
|
-
if (!config.enabled) return
|
|
518
|
-
|
|
519
|
-
const store = createUsageStore(config.poll_interval)
|
|
520
|
-
api.lifecycle.onDispose(store.dispose)
|
|
521
|
-
|
|
522
|
-
// Model fallback notifications (toast) + sidebar status line
|
|
523
|
-
const fallback = createFallbackStore(api)
|
|
524
|
-
api.lifecycle.onDispose(fallback.dispose)
|
|
525
|
-
|
|
526
|
-
// Refresh when session goes idle (inference completed — server just wrote new headers)
|
|
527
|
-
const offEvent = api.event.on("session.idle", () => {
|
|
528
|
-
setTimeout(store.refresh, 1000)
|
|
529
|
-
})
|
|
530
|
-
api.lifecycle.onDispose(offEvent)
|
|
531
|
-
|
|
532
|
-
// Register /usage command
|
|
533
|
-
const offCommand = api.keymap.registerLayer({
|
|
534
|
-
commands: [
|
|
535
|
-
{
|
|
536
|
-
namespace: "palette",
|
|
537
|
-
name: "anthropic-sdk.usage",
|
|
538
|
-
title: "Claude subscription usage",
|
|
539
|
-
category: "Claude",
|
|
540
|
-
slashName: "usage",
|
|
541
|
-
run() {
|
|
542
|
-
store.refresh()
|
|
543
|
-
api.ui.dialog.replace(() => (
|
|
544
|
-
<UsageDialog api={api} usage={store.usage} status={store.status} />
|
|
545
|
-
))
|
|
546
|
-
},
|
|
547
|
-
},
|
|
548
|
-
],
|
|
549
|
-
})
|
|
550
|
-
api.lifecycle.onDispose(offCommand)
|
|
551
|
-
|
|
552
|
-
// Register sidebar slot
|
|
553
|
-
if (config.sidebar) {
|
|
554
|
-
api.slots.register({
|
|
555
|
-
order: 110,
|
|
556
|
-
slots: {
|
|
557
|
-
sidebar_content(ctx, props) {
|
|
558
|
-
return (
|
|
559
|
-
<>
|
|
560
|
-
<UsageSidebar theme={ctx.theme.current} usage={store.usage} status={store.status} />
|
|
561
|
-
<FallbackSidebar
|
|
562
|
-
theme={ctx.theme.current}
|
|
563
|
-
sessionID={(props as { session_id?: string } | undefined)?.session_id}
|
|
564
|
-
active={fallback.active}
|
|
565
|
-
/>
|
|
566
|
-
</>
|
|
567
|
-
)
|
|
568
|
-
},
|
|
569
|
-
},
|
|
570
|
-
})
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const plugin: TuiPluginModule & { id: string } = {
|
|
575
|
-
id,
|
|
576
|
-
tui,
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
export default plugin
|