@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/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