@cortexkit/opencode-anthropic-auth 1.4.1 → 1.6.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,497 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import type {
7
+ TuiPlugin,
8
+ TuiPluginApi,
9
+ TuiPluginModule,
10
+ } from '@opencode-ai/plugin/tui'
11
+ import { For, type JSX, Show, createSignal, onCleanup } from 'solid-js'
12
+
13
+ import {
14
+ type AccountQuota,
15
+ DEFAULT_SIDEBAR_STATE,
16
+ type SidebarState,
17
+ getSidebarState,
18
+ resolveActiveAccount,
19
+ } from './sidebar-state.js'
20
+
21
+ const POLL_MS = 1500
22
+ const REFRESH_DEBOUNCE_MS = 200
23
+
24
+ const ID = 'cortexkit.anthropic-auth'
25
+ const BAR_WIDTH = 10
26
+ const BAR_FILLED = '\u2588'
27
+ const BAR_EMPTY = '\u2591'
28
+
29
+ // Plugin version for the header (mirrors the Magic Context / AFT convention).
30
+ // Read at runtime from package.json relative to this module — NOT a TS JSON
31
+ // import, which would break the declaration build (package.json is outside
32
+ // rootDir). tui.tsx ships as source (package.json exports["./tui"] →
33
+ // "./src/tui.tsx") and is never compiled into a deeper dist tree, so `..` from
34
+ // src/ is always the package root. Empty string on any failure → header shows
35
+ // the badge with no version.
36
+ const PLUGIN_VERSION: string = (() => {
37
+ try {
38
+ const here = dirname(fileURLToPath(import.meta.url))
39
+ const raw = readFileSync(join(here, '..', 'package.json'), 'utf8')
40
+ return (JSON.parse(raw) as { version?: string }).version ?? ''
41
+ } catch {
42
+ return ''
43
+ }
44
+ })()
45
+
46
+ // biome-ignore lint/suspicious/noExplicitAny: opentui border prop not typed in plugin tui surface
47
+ const SINGLE_BORDER = { type: 'single' } as any
48
+
49
+ type Tone = 'ok' | 'warn' | 'err' | 'muted' | 'accent' | 'text'
50
+ type ThemeCurrent = TuiPluginApi['theme']['current']
51
+ type ThemeColor = ThemeCurrent['text']
52
+
53
+ // Tone -> theme token. Theme tokens only — never hardcoded colors. Fallbacks
54
+ // resolve to other theme tokens so the sidebar always tracks the active theme.
55
+ function toneColor(theme: ThemeCurrent, tone: Tone): ThemeColor {
56
+ switch (tone) {
57
+ case 'ok':
58
+ return theme.success ?? theme.accent
59
+ case 'warn':
60
+ return theme.warning ?? theme.accent
61
+ case 'err':
62
+ return theme.error ?? theme.accent
63
+ case 'muted':
64
+ return theme.textMuted ?? theme.text
65
+ case 'accent':
66
+ return theme.accent ?? theme.text
67
+ default:
68
+ return theme.text
69
+ }
70
+ }
71
+
72
+ function usageTone(usedPct: number): Tone {
73
+ if (usedPct < 50) return 'ok'
74
+ if (usedPct < 80) return 'warn'
75
+ return 'err'
76
+ }
77
+
78
+ function quotaBar(usedPct: number, width = BAR_WIDTH): string {
79
+ const filled = Math.max(
80
+ 0,
81
+ Math.min(Math.round((usedPct / 100) * width), width),
82
+ )
83
+ return BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(width - filled)
84
+ }
85
+
86
+ function formatResetIn(resetsAt: string | undefined): string {
87
+ if (!resetsAt) return ''
88
+ const ms = new Date(resetsAt).getTime() - Date.now()
89
+ if (ms <= 0) return 'now'
90
+ const mins = Math.floor(ms / 60_000)
91
+ if (mins < 60) return `${mins}m`
92
+ const hrs = Math.floor(mins / 60)
93
+ const rm = mins % 60
94
+ return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
95
+ }
96
+
97
+ function formatUntil(until: number | undefined): string {
98
+ if (!until) return ''
99
+ const ms = until - Date.now()
100
+ if (ms <= 0) return 'now'
101
+ const mins = Math.ceil(ms / 60_000)
102
+ if (mins < 60) return `${mins}m`
103
+ const hrs = Math.floor(mins / 60)
104
+ const rm = mins % 60
105
+ return rm > 0 ? `${hrs}h${rm}m` : `${hrs}h`
106
+ }
107
+
108
+ // --- Reusable components (aft-style) ---------------------------------------
109
+
110
+ function SectionHeader(props: {
111
+ theme: ThemeCurrent
112
+ title: string
113
+ marginTop?: number
114
+ }) {
115
+ return (
116
+ <box width='100%' marginTop={props.marginTop ?? 1}>
117
+ <text fg={props.theme.text}>
118
+ <b>{props.title}</b>
119
+ </text>
120
+ </box>
121
+ )
122
+ }
123
+
124
+ function StatRow(props: {
125
+ theme: ThemeCurrent
126
+ label: string
127
+ value: string
128
+ tone?: Tone
129
+ }) {
130
+ return (
131
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
132
+ <text fg={props.theme.textMuted}>{props.label}</text>
133
+ <text fg={toneColor(props.theme, props.tone ?? 'text')}>
134
+ <b>{props.value}</b>
135
+ </text>
136
+ </box>
137
+ )
138
+ }
139
+
140
+ // Compact row for the collapsed view: muted label left, caller-provided value
141
+ // right. Mirrors StatRow's layout so columns line up.
142
+ function CollapsedRow(props: {
143
+ theme: ThemeCurrent
144
+ label: string
145
+ children: JSX.Element
146
+ }) {
147
+ return (
148
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
149
+ <text fg={props.theme.textMuted}>{props.label}</text>
150
+ {props.children}
151
+ </box>
152
+ )
153
+ }
154
+
155
+ // Quota window row: muted label left, tone-colored bar + percentage right,
156
+ // with an optional muted reset suffix.
157
+ function QuotaRow(props: {
158
+ theme: ThemeCurrent
159
+ label: string
160
+ window: { usedPercent: number; resetsAt?: string } | undefined
161
+ }) {
162
+ const used = () => props.window?.usedPercent ?? 0
163
+ const reset = () => formatResetIn(props.window?.resetsAt)
164
+ return (
165
+ <Show
166
+ when={props.window}
167
+ fallback={
168
+ <box width='100%' flexDirection='row'>
169
+ <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
170
+ <text fg={props.theme.textMuted}>{'\u2014'}</text>
171
+ </box>
172
+ }
173
+ >
174
+ {/* Left group (label · bar · pct) stays left-aligned in fixed columns so
175
+ bars and percentages line up across rows; the reset time is pushed to
176
+ the right edge so reset times align in their own right column. */}
177
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
178
+ <box flexDirection='row'>
179
+ <text fg={props.theme.textMuted}>{props.label.padEnd(3)}</text>
180
+ <text fg={toneColor(props.theme, usageTone(used()))}>
181
+ {quotaBar(used())}
182
+ </text>
183
+ <text fg={toneColor(props.theme, usageTone(used()))}>
184
+ {` ${String(Math.round(used())).padStart(3)}%`}
185
+ </text>
186
+ </box>
187
+ <Show when={reset()}>
188
+ <text fg={props.theme.textMuted}>{reset()}</text>
189
+ </Show>
190
+ </box>
191
+ </Show>
192
+ )
193
+ }
194
+
195
+ // Account block: header row (name + status word) then per-window quota rows.
196
+ function AccountBlock(props: {
197
+ theme: ThemeCurrent
198
+ name: string
199
+ quota: AccountQuota | null
200
+ active: boolean
201
+ marginTop?: number
202
+ }) {
203
+ const statusWord = () => (props.active ? 'active' : 'idle')
204
+ const statusTone = (): Tone => (props.active ? 'ok' : 'muted')
205
+ return (
206
+ <box width='100%' flexDirection='column' marginTop={props.marginTop ?? 0}>
207
+ <box width='100%' flexDirection='row' justifyContent='space-between'>
208
+ <text fg={props.theme.text}>
209
+ <b>{props.name}</b>
210
+ </text>
211
+ <text fg={toneColor(props.theme, statusTone())}>
212
+ <b>{statusWord()}</b>
213
+ </text>
214
+ </box>
215
+ <Show
216
+ when={props.quota}
217
+ fallback={<text fg={props.theme.textMuted}>{'checking\u2026'}</text>}
218
+ >
219
+ <QuotaRow
220
+ theme={props.theme}
221
+ label='5h'
222
+ window={props.quota?.five_hour}
223
+ />
224
+ <QuotaRow
225
+ theme={props.theme}
226
+ label='7d'
227
+ window={props.quota?.seven_day}
228
+ />
229
+ </Show>
230
+ </box>
231
+ )
232
+ }
233
+
234
+ // --- State plumbing ---------------------------------------------------------
235
+
236
+ async function readStateFromFile(): Promise<SidebarState> {
237
+ return getSidebarState()
238
+ }
239
+
240
+ function QuotaSidebar(props: { api: TuiPluginApi }) {
241
+ const [state, setState] = createSignal<SidebarState>(DEFAULT_SIDEBAR_STATE)
242
+ const [collapsed, setCollapsed] = createSignal(false)
243
+ let lastUpdated = 0
244
+ let debounce: ReturnType<typeof setTimeout> | null = null
245
+
246
+ async function refresh() {
247
+ const next = await readStateFromFile()
248
+ if (next.lastUpdated !== lastUpdated) {
249
+ lastUpdated = next.lastUpdated
250
+ setState(next)
251
+ }
252
+ }
253
+
254
+ function scheduleRefresh() {
255
+ if (debounce) clearTimeout(debounce)
256
+ debounce = setTimeout(() => {
257
+ debounce = null
258
+ void refresh()
259
+ }, REFRESH_DEBOUNCE_MS)
260
+ }
261
+
262
+ // Background poller (server and TUI run as separate module instances, so we
263
+ // sync through the state file) plus event-driven refreshes for low latency.
264
+ const timer = setInterval(refresh, POLL_MS)
265
+ const unsubs = [
266
+ props.api.event.on('session.updated', scheduleRefresh),
267
+ props.api.event.on('message.updated', scheduleRefresh),
268
+ ]
269
+ setTimeout(refresh, 300)
270
+ onCleanup(() => {
271
+ clearInterval(timer)
272
+ if (debounce) clearTimeout(debounce)
273
+ for (const u of unsubs) u()
274
+ })
275
+
276
+ const theme = () => props.api.theme.current
277
+ const enabledFallbacks = () => state().fallbacks.filter((f) => f.enabled)
278
+ const hasData = () =>
279
+ state().main.quota != null || enabledFallbacks().length > 0
280
+
281
+ const headerLabel = () =>
282
+ !hasData() ? 'CLAUDE' : collapsed() ? '\u25b6 CLAUDE' : '\u25bc CLAUDE'
283
+ const activeAccount = () => resolveActiveAccount(state())
284
+ const activeFiveHourPct = () =>
285
+ activeAccount().quota?.five_hour?.usedPercent ?? null
286
+
287
+ const quotaBackedOff = () => state().main.quotaBackedOff === true
288
+ const refreshBackedOff = () => state().main.refreshBackedOff === true
289
+ const degraded = () => quotaBackedOff() || refreshBackedOff()
290
+
291
+ const cacheKeep = () => state().cacheKeep
292
+ const showCache = () => cacheKeep() != null && cacheKeep()?.window != null
293
+ const relayValue = () => {
294
+ const r = state().relay
295
+ if (!r) return '\u2014'
296
+ return `${r.transport} \u00b7 ${r.enabled ? 'on' : 'off'}`
297
+ }
298
+
299
+ return (
300
+ <box
301
+ width='100%'
302
+ flexDirection='column'
303
+ border={SINGLE_BORDER}
304
+ borderColor={theme().borderActive}
305
+ paddingTop={1}
306
+ paddingBottom={1}
307
+ paddingLeft={1}
308
+ paddingRight={1}
309
+ >
310
+ {/* Header: ▼/▶ CLAUDE badge (click to collapse) + version, or LIMITED badge */}
311
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: opentui renders to a terminal, not the DOM — ARIA roles do not apply */}
312
+ <box
313
+ width='100%'
314
+ flexDirection='row'
315
+ justifyContent='space-between'
316
+ alignItems='center'
317
+ onMouseDown={() => {
318
+ if (hasData()) setCollapsed((value) => !value)
319
+ }}
320
+ >
321
+ <box paddingLeft={1} paddingRight={1} backgroundColor={theme().accent}>
322
+ <text fg={theme().background}>
323
+ <b>{headerLabel()}</b>
324
+ </text>
325
+ </box>
326
+ <Show
327
+ when={degraded()}
328
+ fallback={
329
+ <Show when={PLUGIN_VERSION !== ''}>
330
+ <text fg={theme().textMuted}>{`v${PLUGIN_VERSION}`}</text>
331
+ </Show>
332
+ }
333
+ >
334
+ <box
335
+ paddingLeft={1}
336
+ paddingRight={1}
337
+ backgroundColor={theme().warning}
338
+ >
339
+ <text fg={theme().background}>
340
+ <b>{'LIMITED'}</b>
341
+ </text>
342
+ </box>
343
+ </Show>
344
+ </box>
345
+
346
+ {/* Collapsed: active account 5h quota + dot, plus fast-mode when on */}
347
+ <Show when={collapsed() && hasData()}>
348
+ <CollapsedRow theme={theme()} label={activeAccount().name}>
349
+ <Show
350
+ when={activeFiveHourPct() != null}
351
+ fallback={<text fg={theme().textMuted}>{'\u2014'}</text>}
352
+ >
353
+ <box flexDirection='row'>
354
+ <text
355
+ fg={toneColor(
356
+ theme(),
357
+ usageTone(activeFiveHourPct() as number),
358
+ )}
359
+ >
360
+ <b>
361
+ {`${String(Math.round(activeFiveHourPct() as number)).padStart(3)}%`}
362
+ </b>
363
+ </text>
364
+ <text
365
+ fg={toneColor(
366
+ theme(),
367
+ usageTone(activeFiveHourPct() as number),
368
+ )}
369
+ >
370
+ {' \u25cf'}
371
+ </text>
372
+ </box>
373
+ </Show>
374
+ </CollapsedRow>
375
+ <Show when={state().fastMode}>
376
+ <CollapsedRow theme={theme()} label='Mode'>
377
+ <text fg={toneColor(theme(), 'accent')}>
378
+ <b>{'fast'}</b>
379
+ </text>
380
+ </CollapsedRow>
381
+ </Show>
382
+ </Show>
383
+
384
+ {/* Expanded: full sections. Also render when there's no data so the
385
+ sidebar can never go blank if data clears while collapsed. */}
386
+ <Show when={!collapsed() || !hasData()}>
387
+ <Show
388
+ when={hasData()}
389
+ fallback={
390
+ <box marginTop={1} width='100%'>
391
+ <text fg={theme().textMuted}>{'Waiting for quota\u2026'}</text>
392
+ </box>
393
+ }
394
+ >
395
+ {/* Quota */}
396
+ <SectionHeader theme={theme()} title='Quota' />
397
+ <AccountBlock
398
+ theme={theme()}
399
+ name='main'
400
+ quota={state().main.quota}
401
+ active={state().activeId === 'main'}
402
+ />
403
+ <For each={enabledFallbacks()}>
404
+ {(fb) => (
405
+ <AccountBlock
406
+ theme={theme()}
407
+ name={fb.label ?? fb.id}
408
+ quota={fb.quota}
409
+ active={state().activeId === fb.id}
410
+ marginTop={1}
411
+ />
412
+ )}
413
+ </For>
414
+ </Show>
415
+
416
+ {/* Routing */}
417
+ <SectionHeader theme={theme()} title='Routing' />
418
+ <StatRow
419
+ theme={theme()}
420
+ label='Route'
421
+ value={state().route}
422
+ tone='accent'
423
+ />
424
+ <StatRow
425
+ theme={theme()}
426
+ label='Mode'
427
+ value={state().fastMode ? 'fast' : 'std'}
428
+ tone={state().fastMode ? 'accent' : 'muted'}
429
+ />
430
+ <StatRow
431
+ theme={theme()}
432
+ label='Relay'
433
+ value={relayValue()}
434
+ tone={state().relay?.enabled ? 'ok' : 'muted'}
435
+ />
436
+
437
+ {/* Cache */}
438
+ <Show when={showCache()}>
439
+ <SectionHeader theme={theme()} title='Cache' />
440
+ <StatRow
441
+ theme={theme()}
442
+ label='1h cache'
443
+ value={`${cacheKeep()?.window} \u00b7 ${cacheKeep()?.enabled ? 'on' : 'off'}`}
444
+ tone={cacheKeep()?.enabled ? 'ok' : 'muted'}
445
+ />
446
+ <Show when={(cacheKeep()?.trackedSessions ?? 0) > 0}>
447
+ <StatRow
448
+ theme={theme()}
449
+ label='Tracked'
450
+ value={String(cacheKeep()?.trackedSessions)}
451
+ tone='text'
452
+ />
453
+ </Show>
454
+ </Show>
455
+
456
+ {/* Health — only when something is wrong */}
457
+ <Show when={degraded()}>
458
+ <SectionHeader theme={theme()} title='Health' />
459
+ <Show when={quotaBackedOff()}>
460
+ <StatRow
461
+ theme={theme()}
462
+ label='Quota API'
463
+ value={`backoff ${formatUntil(state().main.quotaBackoffUntil)}`}
464
+ tone='warn'
465
+ />
466
+ </Show>
467
+ <Show when={refreshBackedOff()}>
468
+ <StatRow
469
+ theme={theme()}
470
+ label='Token refresh'
471
+ value={`backoff ${formatUntil(state().main.refreshBackoffUntil)}`}
472
+ tone='warn'
473
+ />
474
+ </Show>
475
+ </Show>
476
+ </Show>
477
+ </box>
478
+ )
479
+ }
480
+
481
+ const tui: TuiPlugin = async (api) => {
482
+ api.slots.register({
483
+ order: 160,
484
+ slots: {
485
+ sidebar_content(_ctx: unknown, _props: { session_id: string }) {
486
+ return <QuotaSidebar api={api} />
487
+ },
488
+ },
489
+ })
490
+ }
491
+
492
+ const plugin: TuiPluginModule & { id: string } = {
493
+ id: ID,
494
+ tui,
495
+ }
496
+
497
+ export default plugin